diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index d4ec91047..613ce3235 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -129,7 +129,13 @@ where Fut: Future>>, { let services = build_runtime_services(&ctx); - let req = ctx.into_request(); + let mut req = ctx.into_request(); + if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &state.settings, + &mut req, + ) { + return Ok(http_error(&error)); + } Ok(handler(state, services, req) .await .unwrap_or_else(|e| http_error(&e))) @@ -170,8 +176,9 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) async fn dispatch_fallback( state: &AppState, services: &RuntimeServices, - req: Request, + mut req: Request, ) -> Result> { + trusted_server_core::integrations::gpt_diagnostics::prepare_request(&state.settings, &mut req)?; let path = req.uri().path().to_string(); let method = req.method().clone(); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index b3694fa5a..9f2e40796 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -173,7 +173,13 @@ where let f = f.clone(); Box::pin(async move { let services = build_per_request_services(&ctx); - let req = ctx.into_request(); + let mut req = ctx.into_request(); + if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &s.settings, + &mut req, + ) { + return Ok(http_error(&error)); + } Ok(f(s, services, req).await.unwrap_or_else(|e| http_error(&e))) }) } @@ -361,7 +367,13 @@ fn build_router(state: &Arc) -> RouterService { ctx: RequestContext, ) -> Result { let services = build_per_request_services(&ctx); - let req = ctx.into_request(); + let mut req = ctx.into_request(); + if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &state.settings, + &mut req, + ) { + return Ok(http_error(&error)); + } let path = req.uri().path().to_owned(); let method = req.method().clone(); // tsjs assets are served for GET only, matching the Axum/Fastly adapters. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index a72cbb2f0..5258d3455 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -524,6 +524,13 @@ async fn execute_named( return Ok(run_batch_sync(&state, &services, req)); } + if let Err(report) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &state.settings, + &mut req, + ) { + return Ok(http_error(&report)); + } + let mut ec = build_ec_request_state(&state.settings, &services, &req); // EcContext creation errors short-circuit before filters, mirroring legacy: // the legacy path returns its error response before running filter_request. @@ -703,6 +710,13 @@ async fn dispatch_fallback( let path = req.uri().path().to_string(); let method = req.method().clone(); + if let Err(report) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &state.settings, + &mut req, + ) { + return http_error(&report); + } + let mut ec = build_ec_request_state(&state.settings, services, &req); if let Some(report) = ec.setup_error.take() { let response = http_error(&report); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 9f9d3235f..68f2ddde6 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -524,7 +524,15 @@ fn build_router(state: &Arc) -> RouterService { // `NormalizeMiddleware` before this handler runs, so the signed // OpenRTB metadata that auction signing derives from // `RequestInfo::from_request` uses the trusted runtime authority. - let req = ctx.into_request(); + let mut req = ctx.into_request(); + if let Err(error) = + trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &s.settings, + &mut req, + ) + { + return Ok(http_error(&error)); + } // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it // closed for consented users. @@ -549,7 +557,15 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = build_runtime_services(&ctx); - let req = ctx.into_request(); + let mut req = ctx.into_request(); + if let Err(error) = + trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &s.settings, + &mut req, + ) + { + return Ok(http_error(&error)); + } let ec_context = build_ec_context(&s.settings, &services, &req); let auction = AuctionDispatch { orchestrator: &s.orchestrator, @@ -631,7 +647,13 @@ fn build_router(state: &Arc) -> RouterService { ctx: RequestContext, ) -> Result { let services = build_runtime_services(&ctx); - let req = ctx.into_request(); + let mut req = ctx.into_request(); + if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &state.settings, + &mut req, + ) { + return Ok(http_error(&error)); + } let path = req.uri().path().to_owned(); let method = req.method().clone(); diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 6c7a397b0..96eec2f1f 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -12,6 +12,7 @@ fn make_config() -> HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, } } diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd747..40bf0e6c3 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -18,8 +18,9 @@ use crate::error::TrustedServerError; use crate::integrations::{ adserver_mock::AdServerMockConfig, aps::ApsConfig, datadome::DataDomeConfig, didomi::DidomiIntegrationConfig, google_tag_manager::GoogleTagManagerConfig, gpt::GptConfig, - lockr::LockrConfig, nextjs::NextJsIntegrationConfig, osano::OsanoConfig, - permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, testlight::TestlightConfig, + gpt_diagnostics::GptDiagnosticsConfig, lockr::LockrConfig, nextjs::NextJsIntegrationConfig, + osano::OsanoConfig, permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, + testlight::TestlightConfig, }; use crate::settings::{IntegrationConfig, Settings}; @@ -39,6 +40,7 @@ const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ "google_tag_manager", "datadome", "gpt", + "gpt_diagnostics", ]; /// Typed app-config root used by the `ts` CLI. @@ -155,6 +157,7 @@ fn validate_enabled_integrations( validate_integration::(settings, "google_tag_manager")?; validate_integration::(settings, "datadome")?; validate_integration::(settings, "gpt")?; + validate_integration::(settings, "gpt_diagnostics")?; Ok(enabled_auction_providers) } diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index e906ad79a..889234b56 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -13,6 +13,7 @@ use lol_html::{ text, }; +use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; use crate::integrations::{ AttributeRewriteOutcome, IntegrationAttributeContext, IntegrationDocumentState, IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationRegistry, @@ -172,6 +173,8 @@ pub struct HtmlProcessorConfig { /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the /// full-document buffering done for post-processors is bounded. pub max_buffered_body_bytes: usize, + /// Request-scoped conditional diagnostics delivery decision. + pub gpt_diagnostics: Option, } impl HtmlProcessorConfig { @@ -192,6 +195,7 @@ impl HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, + gpt_diagnostics: None, } } @@ -212,6 +216,13 @@ impl HtmlProcessorConfig { self.ad_bids_state = ad_bids_state; self } + + /// Attach the request-scoped conditional diagnostics decision. + #[must_use] + pub fn with_gpt_diagnostics(mut self, decision: Option) -> Self { + self.gpt_diagnostics = decision; + self + } } /// Create an HTML processor with URL replacement and integration hooks. @@ -294,6 +305,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); + let gpt_diagnostics = config.gpt_diagnostics.clone(); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -303,6 +315,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let patterns = patterns.clone(); let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); + let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); @@ -321,9 +334,23 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso for insert in integrations.head_inserts(&ctx) { snippet.push_str(&insert); } + if let Some(bootstrap) = gpt_diagnostics + .as_ref() + .and_then(GptDiagnosticsRequestDecision::bootstrap_script) + { + snippet.push_str(&bootstrap); + } // Main bundle: core + non-deferred integrations (synchronous). let immediate_ids = integrations.js_module_ids_immediate(); snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); + // Active diagnostics loads synchronously after core so its + // GPT listeners precede publisher scripts in the origin head. + if let Some(module_tag) = gpt_diagnostics + .as_ref() + .and_then(GptDiagnosticsRequestDecision::module_script_tag) + { + snippet.push_str(&module_tag); + } // Deferred bundles: large modules like prebid loaded after // HTML parsing completes. Empty when none are enabled. let deferred_ids = integrations.js_module_ids_deferred(); @@ -664,6 +691,7 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, } } @@ -792,6 +820,80 @@ mod tests { ); } + #[test] + fn active_gpt_diagnostics_loads_standalone_after_unified_bundle_once() { + let html = "Test"; + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &json!({ "enabled": true })) + .expect("should insert GPT diagnostics config"); + + let mut request = http::Request::builder() + .method(http::Method::GET) + .uri("https://publisher.example/page?ts_console=1") + .header("sec-fetch-dest", "document") + .body(edgezero_core::body::Body::empty()) + .expect("should build activation request"); + let decision = + crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut request) + .expect("should prepare diagnostics request"); + let mut config = create_test_config(); + config.integrations = + IntegrationRegistry::new(&settings).expect("should build integration registry"); + config.gpt_diagnostics = Some(decision); + + let processor = create_html_processor(config); + let pipeline_config = PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }; + let mut pipeline = StreamingPipeline::new(pipeline_config, processor); + let mut output = Vec::new(); + + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should process HTML"); + let processed = String::from_utf8(output).expect("should produce valid UTF-8"); + let bootstrap_marker = "__tsjs_gpt_diagnostics_active"; + let bundle_marker = "id=\"trustedserver-js\""; + let diagnostics_marker = "tsjs-gpt_diagnostics.min.js"; + + assert_eq!( + processed.matches(bootstrap_marker).count(), + 1, + "should inject the diagnostics bootstrap once" + ); + assert_eq!( + processed.matches(bundle_marker).count(), + 1, + "should inject the immediate TSJS bundle once" + ); + assert_eq!( + processed.matches(diagnostics_marker).count(), + 1, + "should inject one standalone diagnostics module" + ); + let bootstrap_index = processed + .find(bootstrap_marker) + .expect("should include diagnostics bootstrap"); + let bundle_index = processed + .find(bundle_marker) + .expect("should include immediate TSJS bundle"); + let diagnostics_index = processed + .find(diagnostics_marker) + .expect("should include standalone diagnostics module"); + assert!( + bootstrap_index < bundle_index, + "should activate before core executes" + ); + assert!( + bundle_index < diagnostics_index, + "should load diagnostics after core" + ); + } + #[test] fn test_create_html_processor_url_replacement() { let config = create_test_config(); @@ -1436,6 +1538,7 @@ mod tests { ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, }; let mut processor = create_html_processor(config); let output = processor @@ -1509,6 +1612,7 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, }; let mut processor = create_html_processor(config); let output = processor @@ -1544,6 +1648,7 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, }; let mut processor = create_html_processor(config); // Malformed HTML with two elements (common in CMS template pages) @@ -1578,6 +1683,7 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, }; let mut processor = create_html_processor(config); let output = processor @@ -1630,6 +1736,7 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, }; let mut processor = create_html_processor(config); let output = processor @@ -1656,6 +1763,7 @@ mod tests { ad_slots_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + gpt_diagnostics: None, }; let mut processor = create_html_processor(config); let output = processor diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs new file mode 100644 index 000000000..15388cfcc --- /dev/null +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -0,0 +1,524 @@ +//! Query-activated, browser-session GPT runtime diagnostics integration. +//! +//! Deployment configuration makes the standalone browser module available. +//! Exact `ts_console` directives establish or clear a host-only session cookie; +//! active documents load the module synchronously without adding diagnostics to +//! the ordinary unified bundle. + +use error_stack::{Report, ResultExt}; +use http::{HeaderValue, Method, Request, Response, Uri, header, uri::PathAndQuery}; +use serde::Deserialize; +use validator::Validate; + +use edgezero_core::body::Body as EdgeBody; + +use crate::error::TrustedServerError; +use crate::http_util::is_navigation_request; +use crate::response_privacy::SURROGATE_CACHE_HEADERS; +use crate::settings::{IntegrationConfig, Settings}; +use crate::tsjs; + +use super::IntegrationRegistration; + +/// Stable integration identifier. +pub const GPT_DIAGNOSTICS_INTEGRATION_ID: &str = "gpt_diagnostics"; +/// Reserved activation query parameter. +pub const GPT_DIAGNOSTICS_QUERY: &str = "ts_console"; +/// Host-only browser-session activation cookie. +pub const GPT_DIAGNOSTICS_COOKIE: &str = "__Host-ts-console"; + +const SET_CONSOLE_COOKIE: &str = "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax"; +const CLEAR_CONSOLE_COOKIE: &str = + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0"; + +/// Configuration for the GPT runtime diagnostics integration. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GptDiagnosticsConfig { + /// Whether the GPT diagnostics browser module is available. + #[serde(default)] + pub enabled: bool, +} + +impl IntegrationConfig for GptDiagnosticsConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// Cookie mutation requested by an activation directive. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GptDiagnosticsCookieAction { + /// Do not mutate the activation cookie. + #[default] + None, + /// Establish a host-only browser-session activation cookie. + SetSession, + /// Clear the activation cookie. + ClearSession, +} + +/// Immutable request-scoped diagnostics decision. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GptDiagnosticsRequestDecision { + active: bool, + clean_browser_path_and_query: Option, + cookie_action: GptDiagnosticsCookieAction, +} + +impl GptDiagnosticsRequestDecision { + /// Whether this document should install diagnostics. + #[must_use] + pub fn active(&self) -> bool { + self.active + } + + /// Whether the response must be private and non-storeable. + #[must_use] + pub fn requires_private_no_store(&self) -> bool { + self.active + || self.cookie_action != GptDiagnosticsCookieAction::None + || self.clean_browser_path_and_query.is_some() + } + + /// Build the early activation/URL-cleanup bootstrap for an HTML document. + #[must_use] + pub fn bootstrap_script(&self) -> Option { + if !self.active && self.clean_browser_path_and_query.is_none() { + return None; + } + + let mut script = String::from(""); + Some(script) + } + + /// Build the synchronous standalone diagnostics module tag. + #[must_use] + pub fn module_script_tag(&self) -> Option { + self.active.then(|| { + format!( + "", + tsjs::tsjs_single_module_script_src(GPT_DIAGNOSTICS_INTEGRATION_ID) + ) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueryDirective { + Absent, + Enable, + Disable, + Invalid, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ConsoleCookieState { + occurrences: usize, + canonical: bool, +} + +/// Register GPT diagnostics when explicitly enabled. +/// +/// # Errors +/// +/// Returns an error when the integration configuration cannot be parsed or +/// fails validation. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(_config) = + settings.integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID)? + else { + return Ok(None); + }; + + Ok(Some( + IntegrationRegistration::builder(GPT_DIAGNOSTICS_INTEGRATION_ID) + .without_js() + .build(), + )) +} + +/// Evaluate activation and sanitize the request before generic cookie handling. +/// +/// The reserved query and cookie are always removed before the request reaches +/// publisher, auction, or origin logic. Activation directives apply only to +/// eligible GET document navigations. Duplicate/invalid directives and duplicate +/// cookies fail closed for the current response. +/// +/// # Errors +/// +/// Returns an error when configuration parsing or URI reconstruction fails. +pub fn prepare_request( + settings: &Settings, + request: &mut Request, +) -> Result> { + if let Some(existing) = request.extensions().get::() { + return Ok(existing.clone()); + } + + let integration_enabled = settings + .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID)? + .is_some(); + let (directive, clean_path, had_reserved_query) = console_query(request.uri()); + let cookie_state = console_cookie_state(request); + let eligible_navigation = request.method() == Method::GET + && is_navigation_request(request) + && !crate::publisher::is_prefetch_request(request) + && !crate::publisher::is_bot_user_agent(request); + + sanitize_console_cookie(request); + if had_reserved_query { + replace_path_and_query(request, &clean_path)?; + } + + let mut decision = GptDiagnosticsRequestDecision::default(); + if integration_enabled && eligible_navigation && had_reserved_query { + decision.clean_browser_path_and_query = Some(clean_path); + match directive { + QueryDirective::Enable => { + decision.active = true; + decision.cookie_action = GptDiagnosticsCookieAction::SetSession; + } + QueryDirective::Disable => { + decision.cookie_action = GptDiagnosticsCookieAction::ClearSession; + } + QueryDirective::Invalid | QueryDirective::Absent => {} + } + } else if integration_enabled + && directive == QueryDirective::Absent + && cookie_state.occurrences == 1 + && cookie_state.canonical + && eligible_navigation + { + decision.active = true; + } + + request.extensions_mut().insert(decision.clone()); + Ok(decision) +} + +/// Read the request decision, defaulting to inactive when not prepared. +#[must_use] +pub fn request_decision(request: &Request) -> GptDiagnosticsRequestDecision { + request + .extensions() + .get::() + .cloned() + .unwrap_or_default() +} + +/// Apply activation cookie and strict cache privacy to an origin response. +pub fn finalize_response( + decision: &GptDiagnosticsRequestDecision, + response: &mut Response, +) { + let cookie = match decision.cookie_action { + GptDiagnosticsCookieAction::None => None, + GptDiagnosticsCookieAction::SetSession => { + Some(HeaderValue::from_static(SET_CONSOLE_COOKIE)) + } + GptDiagnosticsCookieAction::ClearSession => { + Some(HeaderValue::from_static(CLEAR_CONSOLE_COOKIE)) + } + }; + if let Some(cookie) = cookie { + response.headers_mut().append(header::SET_COOKIE, cookie); + } + + if decision.requires_private_no_store() { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + for name in SURROGATE_CACHE_HEADERS { + response.headers_mut().remove(*name); + } + } +} + +fn console_query(uri: &Uri) -> (QueryDirective, String, bool) { + let mut console_values = Vec::new(); + let mut retained = Vec::new(); + for pair in uri.query().unwrap_or_default().split('&') { + let (name, value) = pair.split_once('=').unwrap_or((pair, "")); + if name == GPT_DIAGNOSTICS_QUERY { + console_values.push(value); + } else { + retained.push(pair); + } + } + + let directive = match console_values.as_slice() { + [] => QueryDirective::Absent, + ["true" | "1"] => QueryDirective::Enable, + ["false" | "0"] => QueryDirective::Disable, + _ => QueryDirective::Invalid, + }; + let mut clean = uri.path().to_owned(); + let retained_query = retained.join("&"); + if !retained_query.is_empty() { + clean.push('?'); + clean.push_str(&retained_query); + } + (directive, clean, !console_values.is_empty()) +} + +fn console_cookie_state(request: &Request) -> ConsoleCookieState { + let mut state = ConsoleCookieState::default(); + for value in request.headers().get_all(header::COOKIE) { + let Ok(value) = value.to_str() else { + continue; + }; + for cookie in value.split(';') { + let cookie = cookie.trim(); + match cookie.split_once('=') { + Some((name, value)) if name.trim() == GPT_DIAGNOSTICS_COOKIE => { + state.occurrences += 1; + state.canonical |= value.trim() == "1"; + } + None if cookie == GPT_DIAGNOSTICS_COOKIE => state.occurrences += 1, + _ => {} + } + } + } + state +} + +fn sanitize_console_cookie(request: &mut Request) { + let retained = request + .headers() + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .map(str::trim) + .filter(|cookie| match cookie.split_once('=') { + Some((name, _)) => name.trim() != GPT_DIAGNOSTICS_COOKIE, + None => *cookie != GPT_DIAGNOSTICS_COOKIE, + }) + .filter(|cookie| !cookie.is_empty()) + .map(str::to_owned) + .collect::>(); + + request.headers_mut().remove(header::COOKIE); + if !retained.is_empty() { + let value = HeaderValue::from_str(&retained.join("; ")) + .expect("should preserve already-valid cookie header values"); + request.headers_mut().insert(header::COOKIE, value); + } +} + +fn replace_path_and_query( + request: &mut Request, + clean_path_and_query: &str, +) -> Result<(), Report> { + let mut parts = request.uri().clone().into_parts(); + parts.path_and_query = Some( + clean_path_and_query + .parse::() + .change_context(TrustedServerError::Proxy { + message: "GPT diagnostics query produced invalid URI".to_owned(), + })?, + ); + *request.uri_mut() = Uri::from_parts(parts).change_context(TrustedServerError::Proxy { + message: "GPT diagnostics query produced invalid URI".to_owned(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::integrations::IntegrationRegistry; + use crate::test_support::tests::create_test_settings; + use serde_json::json; + + fn settings(enabled: bool) -> Settings { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + GPT_DIAGNOSTICS_INTEGRATION_ID, + &json!({ "enabled": enabled }), + ) + .expect("should insert diagnostics config"); + settings + } + + fn navigation(uri: &str, cookie: Option<&str>) -> Request { + let mut builder = Request::builder() + .method(Method::GET) + .uri(uri) + .header("sec-fetch-dest", "document"); + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + builder + .body(EdgeBody::empty()) + .expect("should build request") + } + + #[test] + fn register_excludes_diagnostics_from_unified_and_deferred_bundles() { + let registry = IntegrationRegistry::new(&settings(true)).expect("should build registry"); + + assert!(registry.integration_enabled(GPT_DIAGNOSTICS_INTEGRATION_ID)); + assert!( + !registry + .js_module_ids_immediate() + .contains(&GPT_DIAGNOSTICS_INTEGRATION_ID) + ); + assert!( + !registry + .js_module_ids_deferred() + .contains(&GPT_DIAGNOSTICS_INTEGRATION_ID) + ); + } + + #[test] + fn exact_directive_activates_cleans_and_strips_cookie() { + let mut request = navigation( + "https://publisher.example/page?keep=%2F&ts_console=true#fragment", + Some("other=value; __Host-ts-console=1"), + ); + + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + + assert!(decision.active()); + assert_eq!( + decision.cookie_action, + GptDiagnosticsCookieAction::SetSession + ); + assert_eq!( + request.uri().to_string(), + "https://publisher.example/page?keep=%2F" + ); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + let bootstrap = decision.bootstrap_script().expect("should bootstrap"); + assert!(bootstrap.contains("__tsjs_gpt_diagnostics_active=true")); + assert!(bootstrap.contains("/page?keep=%2F")); + } + + #[test] + fn prefetch_directive_is_sanitized_without_activating_session() { + let mut request = navigation("https://publisher.example/page?ts_console=1&keep=1", None); + request + .headers_mut() + .insert("purpose", HeaderValue::from_static("prefetch")); + + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + + assert!( + !decision.active(), + "prefetch should not activate diagnostics" + ); + assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); + assert_eq!(request.uri().query(), Some("keep=1")); + } + + #[test] + fn active_cookie_enables_clean_navigation_but_duplicates_fail_closed() { + let mut active = navigation( + "https://publisher.example/page", + Some("__Host-ts-console=1; other=value"), + ); + let decision = prepare_request(&settings(true), &mut active).expect("should prepare"); + assert!(decision.active()); + assert_eq!(active.headers()[header::COOKIE], "other=value"); + + let mut duplicate = navigation( + "https://publisher.example/page", + Some("__Host-ts-console=1; __Host-ts-console=1; other=value"), + ); + let decision = prepare_request(&settings(true), &mut duplicate).expect("should prepare"); + assert!(!decision.active()); + assert_eq!(duplicate.headers()[header::COOKIE], "other=value"); + } + + #[test] + fn invalid_duplicate_and_disable_directives_fail_closed() { + for query in [ + "ts_console=True", + "ts_console=", + "ts_console=1&ts_console=true", + ] { + let mut request = navigation( + &format!("https://publisher.example/page?{query}&keep=1"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + assert!(!decision.active(), "{query} should fail closed"); + assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); + assert_eq!(request.uri().query(), Some("keep=1")); + } + + let mut request = navigation( + "https://publisher.example/page?ts_console=false&keep=1", + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + assert!(!decision.active()); + assert_eq!( + decision.cookie_action, + GptDiagnosticsCookieAction::ClearSession + ); + } + + #[test] + fn finalization_sets_cookie_and_strips_shared_cache_headers() { + let mut request = navigation("https://publisher.example/?ts_console=1", None); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let mut response = Response::builder() + .header(header::CACHE_CONTROL, "public, max-age=60") + .header("surrogate-control", "max-age=60") + .header("fastly-surrogate-control", "max-age=60") + .header("cloudflare-cdn-cache-control", "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + + finalize_response(&decision, &mut response); + + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_eq!(response.headers()[header::SET_COOKIE], SET_CONSOLE_COOKIE); + assert!(!response.headers().contains_key("surrogate-control")); + assert!(!response.headers().contains_key("fastly-surrogate-control")); + assert!( + !response + .headers() + .contains_key("cloudflare-cdn-cache-control") + ); + } + + #[test] + fn config_rejects_unknown_fields() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + GPT_DIAGNOSTICS_INTEGRATION_ID, + &json!({ "enabled": true, "typo": true }), + ) + .expect("should insert diagnostics config"); + + let error = settings + .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) + .expect_err("should reject unknown diagnostics config fields"); + let error_text = format!("{error:?}"); + assert!(error_text.contains("typo") || error_text.contains("unknown field")); + } +} diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index bf6d63216..50df4b393 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -17,6 +17,7 @@ pub mod datadome; pub mod didomi; pub mod google_tag_manager; pub mod gpt; +pub mod gpt_diagnostics; pub mod lockr; pub mod nextjs; pub mod osano; @@ -332,6 +333,10 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { id: "gpt", build: gpt::register, }, + IntegrationBuilder { + id: "gpt_diagnostics", + build: gpt_diagnostics::register, + }, ] } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 6f1b4dcfd..c81ccc4f7 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1104,6 +1104,12 @@ impl IntegrationRegistry { map.into_values().collect() } + /// Return whether an integration is enabled in this registry. + #[must_use] + pub fn integration_enabled(&self, integration_id: &str) -> bool { + self.inner.enabled_integration_ids.contains(&integration_id) + } + /// Return JS module IDs that should be included in the tsjs bundle. /// /// Always includes JS-only modules with no Rust-side registration. diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs index 24c899f39..ad3e5350c 100644 --- a/crates/trusted-server-core/src/migration_guards.rs +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -115,6 +115,10 @@ fn checked_sources() -> &'static [(&'static str, &'static str)] { include_str!("integrations/google_tag_manager.rs"), ), ("integrations/gpt.rs", include_str!("integrations/gpt.rs")), + ( + "integrations/gpt_diagnostics.rs", + include_str!("integrations/gpt_diagnostics.rs"), + ), ( "integrations/lockr.rs", include_str!("integrations/lockr.rs"), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index f600a5979..a15a999f0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -305,10 +305,15 @@ pub fn handle_tsjs_dynamic( return Ok(resp); } - if let Some(module_id) = parse_deferred_module_filename(filename) { - // Only serve if the deferred module is actually enabled + if let Some(module_id) = parse_single_module_filename(filename) { + // Deferred modules and the conditionally injected diagnostics module + // are served as content-addressed standalone assets. Delivery remains + // cookie-independent so the static response can stay publicly cached. let deferred_ids = integration_registry.js_module_ids_deferred(); - if !deferred_ids.contains(&module_id) { + let diagnostics_standalone = module_id + == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID + && integration_registry.integration_enabled(module_id); + if !deferred_ids.contains(&module_id) && !diagnostics_standalone { return Ok(not_found_response()); } if let Some(content) = trusted_server_js::module_bundle(module_id) { @@ -329,7 +334,7 @@ pub fn handle_tsjs_dynamic( /// `None` otherwise. The caller must additionally verify that the module is /// both deferred and enabled via the [`IntegrationRegistry`]. #[must_use] -fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { +fn parse_single_module_filename(filename: &str) -> Option<&'static str> { let stem = filename .strip_prefix("tsjs-") .and_then(|s| s.strip_suffix(".min.js").or_else(|| s.strip_suffix(".js")))?; @@ -351,6 +356,8 @@ struct ProcessResponseParams<'a> { integration_registry: &'a IntegrationRegistry, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, + gpt_diagnostics: + Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>, } struct PublisherBodyProcessor { @@ -367,15 +374,16 @@ impl PublisherBodyProcessor { let is_rsc_flight = content_type_contains_ascii_case_insensitive(¶ms.content_type, "text/x-component"); let inner: Box = if is_html { - Box::new(create_html_stream_processor( - ¶ms.origin_host, - ¶ms.request_host, - ¶ms.request_scheme, + Box::new(create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - Arc::clone(¶ms.ad_bids_state), - )?) + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: Arc::clone(¶ms.ad_bids_state), + gpt_diagnostics: params.gpt_diagnostics.clone(), + })?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( ¶ms.origin_host, @@ -443,15 +451,16 @@ fn process_response_streaming( let max_pending_decoded_bytes = params.settings.publisher.max_buffered_body_bytes; if is_html { - let processor = create_html_stream_processor( - params.origin_host, - params.request_host, - params.request_scheme, - params.settings, - params.integration_registry, - params.ad_slots_script.map(str::to_string), - params.ad_bids_state.clone(), - )?; + let processor = create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: params.origin_host, + request_host: params.request_host, + request_scheme: params.request_scheme, + settings: params.settings, + integration_registry: params.integration_registry, + ad_slots_script: params.ad_slots_script.map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + gpt_diagnostics: params.gpt_diagnostics.cloned(), + })?; StreamingPipeline::new(config, processor) .with_max_pending_decoded_bytes(max_pending_decoded_bytes) .process(body_as_reader(body)?, output)?; @@ -926,25 +935,31 @@ async fn hold_finish_tail_segments( /// `use<>` states that explicitly: without it, Rust 2024 would have the opaque /// type capture every input lifetime, forcing callers to keep the settings and /// registry alive for as long as the processor. -fn create_html_stream_processor( - origin_host: &str, - request_host: &str, - request_scheme: &str, - settings: &Settings, - integration_registry: &IntegrationRegistry, +struct HtmlStreamProcessorParams<'a> { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, ad_slots_script: Option, ad_bids_state: Arc>>, + gpt_diagnostics: Option, +} + +fn create_html_stream_processor( + params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, - origin_host, - request_host, - request_scheme, + params.settings, + params.integration_registry, + params.origin_host, + params.request_host, + params.request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state(params.ad_slots_script, params.ad_bids_state) + .with_gpt_diagnostics(params.gpt_diagnostics); Ok(create_html_processor(config)) } @@ -1065,6 +1080,9 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, + /// Request-scoped conditional diagnostics delivery decision. + pub(crate) gpt_diagnostics: + Option, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -1528,6 +1546,7 @@ pub fn stream_publisher_body( integration_registry, ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: ¶ms.ad_bids_state, + gpt_diagnostics: params.gpt_diagnostics.as_ref(), }; process_response_streaming(body, output, &borrowed) } @@ -1612,15 +1631,16 @@ pub async fn stream_publisher_body_async( // HTML: build the processor once and drive it chunk by chunk. // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin // EOF, then await auction and process chunk N (which contains ). - let mut processor = match create_html_stream_processor( - ¶ms.origin_host, - ¶ms.request_host, - ¶ms.request_scheme, + let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - params.ad_bids_state.clone(), - ) { + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + gpt_diagnostics: params.gpt_diagnostics.clone(), + }) { Ok(processor) => processor, Err(err) => { emit_abandoned_auction( @@ -2521,6 +2541,11 @@ pub async fn handle_publisher_request( ) -> Result> { log::debug!("Proxying request to publisher_origin"); + // Adapter fallbacks prepare this before EC/cookie handling. Keep this + // idempotent call as a direct-handler safety net and for focused tests. + let gpt_diagnostics = + crate::integrations::gpt_diagnostics::prepare_request(settings, &mut req)?; + // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. @@ -2853,6 +2878,8 @@ pub async fn handle_publisher_request( response.headers().len() ); + crate::integrations::gpt_diagnostics::finalize_response(&gpt_diagnostics, &mut response); + let ad_slots_script = if should_run_ad_stack { settings .creative_opportunities @@ -2996,6 +3023,7 @@ pub async fn handle_publisher_request( auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + gpt_diagnostics: Some(gpt_diagnostics), }), }) } @@ -4176,6 +4204,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: Default::default(), + gpt_diagnostics: None, } } @@ -4216,6 +4245,49 @@ mod tests { .expect("should build test request") } + #[test] + fn stream_publisher_body_injects_active_diagnostics_for_materialized_html() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let mut request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article?ts_console=1") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build activation request"); + let decision = + crate::integrations::gpt_diagnostics::prepare_request(&settings, &mut request) + .expect("should prepare diagnostics request"); + let mut params = make_stream_params(&settings, ""); + params.content_type = "text/html".to_owned(); + params.gpt_diagnostics = Some(decision); + let mut output = Vec::new(); + + stream_publisher_body( + EdgeBody::from("Example"), + &mut output, + ¶ms, + &settings, + &integration_registry, + ) + .expect("should process materialized HTML"); + + let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); + assert!( + html.contains("__tsjs_gpt_diagnostics_active"), + "should inject the activation flag" + ); + assert!( + html.contains("tsjs-gpt_diagnostics.min.js"), + "should inject the standalone diagnostics module" + ); + } + #[test] fn stream_publisher_body_round_trips_gzip() { let settings = create_test_settings(); @@ -5238,38 +5310,70 @@ mod tests { } #[test] - fn parse_deferred_module_filename_extracts_known_id() { + fn tsjs_dynamic_serves_diagnostics_standalone_without_cookie_variance() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let mut req = build_request( + Method::GET, + "https://publisher.example/static/tsjs=tsjs-gpt_diagnostics.min.js", + ); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("__Host-ts-console=1"), + ); + + let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + assert!( + !response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("private") || value.contains("no-store")), + "standalone module should remain cookie-independent and publicly cacheable" + ); + } + + #[test] + fn parse_single_module_filename_extracts_known_id() { assert_eq!( - parse_deferred_module_filename("tsjs-sourcepoint.min.js"), + parse_single_module_filename("tsjs-sourcepoint.min.js"), Some("sourcepoint"), "should extract sourcepoint from minified filename" ); assert_eq!( - parse_deferred_module_filename("tsjs-sourcepoint.js"), + parse_single_module_filename("tsjs-sourcepoint.js"), Some("sourcepoint"), "should extract sourcepoint from unminified filename" ); } #[test] - fn parse_deferred_module_filename_rejects_unknown_ids() { + fn parse_single_module_filename_rejects_unknown_ids() { assert_eq!( - parse_deferred_module_filename("tsjs-evil.min.js"), + parse_single_module_filename("tsjs-evil.min.js"), None, "should reject unknown module names" ); assert_eq!( - parse_deferred_module_filename("tsjs-core.min.js"), + parse_single_module_filename("tsjs-core.min.js"), Some("core"), "should accept any known module ID (deferred check happens in caller)" ); assert_eq!( - parse_deferred_module_filename("prebid.min.js"), + parse_single_module_filename("prebid.min.js"), None, "should reject without tsjs- prefix" ); assert_eq!( - parse_deferred_module_filename("tsjs-sourcepoint.txt"), + parse_single_module_filename("tsjs-sourcepoint.txt"), None, "should reject non-js extension" ); @@ -5407,6 +5511,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -5454,6 +5559,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -5490,6 +5596,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), @@ -5604,6 +5711,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), @@ -5656,6 +5764,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let compressed = gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -5711,6 +5820,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let compressed = deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -5766,6 +5876,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -5821,6 +5932,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -5864,6 +5976,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, } } @@ -6057,6 +6170,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"hello"), @@ -6120,6 +6234,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; // The `` that triggers bid injection lives in the SECOND gzip // member. `flate2::read::GzDecoder` decodes only the first member, so @@ -6182,6 +6297,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( b"body{background:url('https://origin.example.com/asset.png')}", @@ -6237,6 +6353,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let publisher_response = PublisherResponse::Stream { response, @@ -6372,6 +6489,7 @@ mod tests { auction_request: dispatched_auction.as_ref().map(|_| test_auction_request()), dispatched_auction, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, } } @@ -6722,6 +6840,7 @@ mod tests { 10, )), price_granularity: PriceGranularity::default(), + gpt_diagnostics: None, } }; let make_stream_response = || PublisherResponse::Stream { @@ -6900,6 +7019,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let publisher_response = PublisherResponse::Stream { response, @@ -6966,6 +7086,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -7015,6 +7136,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -7122,6 +7244,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -7178,6 +7301,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + gpt_diagnostics: None, }; let mut output = Vec::new(); diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 27a94b62d..324853cd8 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -17,7 +17,11 @@ use crate::settings::Settings; /// /// A single source of truth so the adapter copies of the privacy downgrade /// cannot drift apart. -pub const SURROGATE_CACHE_HEADERS: &[&str] = &["surrogate-control", "fastly-surrogate-control"]; +pub const SURROGATE_CACHE_HEADERS: &[&str] = &[ + "surrogate-control", + "fastly-surrogate-control", + "cloudflare-cdn-cache-control", +]; /// Forces cookie-bearing responses to stay private to shared caches. /// @@ -83,7 +87,8 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: if response_is_uncacheable && (key.eq_ignore_ascii_case(header::CACHE_CONTROL.as_str()) || key.eq_ignore_ascii_case("surrogate-control") - || key.eq_ignore_ascii_case("fastly-surrogate-control")) + || key.eq_ignore_ascii_case("fastly-surrogate-control") + || key.eq_ignore_ascii_case("cloudflare-cdn-cache-control")) { continue; } @@ -149,6 +154,7 @@ mod tests { let mut response = response_builder() .header(header::SET_COOKIE, "id=abc") .header("surrogate-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "public, max-age=600") .body(edgezero_core::body::Body::empty()) .expect("should build response"); @@ -166,6 +172,12 @@ mod tests { !response.headers().contains_key("surrogate-control"), "surrogate cache headers must be stripped on cookie responses" ); + assert!( + !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "Cloudflare cache policy must be stripped on cookie responses" + ); } #[test] @@ -176,6 +188,7 @@ mod tests { ("set-cookie", "operator=abc"), ("cache-control", "public, max-age=600"), ("surrogate-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "public, max-age=600"), ]); let mut response = response_builder() .body(edgezero_core::body::Body::empty()) @@ -195,12 +208,45 @@ mod tests { !response.headers().contains_key("surrogate-control"), "surrogate cache headers must be stripped when operator headers add Set-Cookie" ); + assert!( + !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "Cloudflare cache policy must be stripped when operator headers add Set-Cookie" + ); assert!( response.headers().contains_key(header::SET_COOKIE), "the operator Set-Cookie itself should still be applied" ); } + #[test] + fn preserves_private_no_store_against_operator_cache_headers_without_cookie() { + let settings = settings_with_response_headers(&[ + ("cache-control", "public, max-age=600"), + ("surrogate-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "public, max-age=600"), + ]); + let mut response = response_builder() + .header(header::CACHE_CONTROL, "private, no-store") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store", + "operator cache headers must not weaken an existing private response" + ); + assert!(!response.headers().contains_key("surrogate-control")); + assert!( + !response + .headers() + .contains_key("cloudflare-cdn-cache-control") + ); + } + #[test] fn applies_operator_headers_on_cookieless_response() { let settings = settings_with_response_headers(&[("x-operator", "value")]); diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 45aee02eb..280b88bb3 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -38,13 +38,19 @@ pub fn tsjs_unified_script_tag() -> String { tsjs_script_tag(&ids) } -/// `/static` URL for a single deferred module with its own cache-busting hash. +/// `/static` URL for one module with its own cache-busting hash. #[must_use] -pub fn tsjs_deferred_script_src(module_id: &str) -> String { +pub fn tsjs_single_module_script_src(module_id: &str) -> String { let hash = single_module_hash(module_id).unwrap_or_default(); format!("/static/tsjs=tsjs-{module_id}.min.js?v={hash}") } +/// `/static` URL for a single deferred module with its own cache-busting hash. +#[must_use] +pub fn tsjs_deferred_script_src(module_id: &str) -> String { + tsjs_single_module_script_src(module_id) +} + /// `