From ff782e0f1e9a2a005fcf7b32128d9ca7795dc45d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 14:46:47 +0530 Subject: [PATCH 001/494] Quantize auction transport timeouts to stabilize Fastly backend names Fastly dynamic backend names embed the first-byte and between-bytes timeouts so a registration can never be silently reused with a different transport configuration. Deriving those timeouts from the remaining wall-clock auction budget minted a new backend name on nearly every request, defeating cross-request TCP/TLS connection reuse (Fastly pools connections per backend name) and accumulating registrations toward the per-service dynamic backend limit. Compute the effective transport timeout from the configured provider timeout verbatim when the budget allows, floor the budget-bound value to 250ms buckets otherwise, and pass sub-quantum remainders through exactly so publishers with sub-250ms configured budgets keep launching. The quantized value feeds both the backend name and the registered configuration, so they cannot diverge. Rounding down never extends a transport cap past the auction deadline, which the mediator and dispatched-collect paths rely on to bound the hold. Also add a Fastly platform test pinning predict_name == ensure for the same spec, since the orchestrator maps responses back to providers by predicted backend name. Fixes #847 --- .../src/platform.rs | 31 + .../src/auction/orchestrator.rs | 628 ++++++++++++++++-- 2 files changed, 617 insertions(+), 42 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 106ced787..c5bb60b9c 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -736,6 +736,37 @@ mod tests { ); } + #[test] + fn predict_name_matches_ensured_backend_name() { + // The auction orchestrator maps responses back to providers by the + // predicted backend name, so predict_name and ensure must return the + // identical string for the same spec — a divergence would make + // responses land in the "unknown backend" branch and drop bids + // silently. + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "consistency.example.com".to_string(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + }; + + let predicted = backend + .predict_name(&spec) + .expect("should predict backend name"); + let ensured = backend + .ensure(&spec) + .expect("should register backend for valid spec"); + + assert_eq!( + predicted, ensured, + "predicted backend name should match the registered backend name" + ); + } + // --- FastlyPlatformHttpClient ------------------------------------------- #[test] diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 145059d9e..d884a0220 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -157,6 +157,64 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } +/// Transport-timeout quantum for auction backends. +/// +/// See [`quantize_transport_timeout_ms`] for why provider transport timeouts +/// are rounded to this granularity. +const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; + +/// Round a transport timeout down to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple. +/// +/// The Fastly adapter embeds the first-byte and between-bytes timeouts in the +/// dynamic backend name so a registration can never be silently reused with a +/// different transport configuration. Deriving those timeouts from the +/// remaining wall-clock budget minted a new backend name on nearly every +/// request, which defeated cross-request TCP/TLS connection reuse (Fastly +/// pools connections per backend name) and accumulated registrations toward +/// the per-service dynamic backend limit. +/// +/// Quantizing the value — not just the name — keeps the registered backend +/// configuration aligned with its name. Rounding down never extends a +/// transport cap past the auction deadline, which matters on the mediator and +/// dispatched-collect paths where the backend timeouts (not a select-loop +/// deadline check) bound the `` hold. +#[inline] +fn quantize_transport_timeout_ms(timeout_ms: u32) -> u32 { + (timeout_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS +} + +/// Compute the transport timeout for a provider launch from the remaining +/// auction budget and the provider's configured timeout. +/// +/// The configured timeout is a per-provider constant, so using it verbatim +/// already yields a stable backend name — including configured values below +/// one quantum, which must not be rounded away or the provider could never +/// launch. Only when the remaining budget is the binding constraint does the +/// wall-clock-derived value enter the name, and that value is quantized via +/// [`quantize_transport_timeout_ms`] so it cannot mint a new backend name on +/// every request. +/// +/// A remaining budget below one quantum is passed through exactly rather +/// than rounded to zero: rounding up would extend the transport cap past the +/// deadline, and rounding down would skip the launch and hard-fail auctions +/// whose configured budget is under one quantum. Name churn in this regime +/// is bounded to sub-quantum values and matches the pre-quantization +/// behavior. The result never exceeds `remaining_ms` and is zero only when +/// `remaining_ms` or `configured_ms` is zero, which callers treat as +/// "budget exhausted — skip the launch". +#[inline] +fn effective_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + let quantized = quantize_transport_timeout_ms(remaining_ms); + if quantized == 0 { + remaining_ms + } else { + quantized + } +} + /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, @@ -279,10 +337,14 @@ impl AuctionOrchestrator { // Give the mediator only the remaining time from the auction // deadline, not the full timeout — the bidding phase already - // consumed part of it. + // consumed part of it, and the mediator has no select-loop + // deadline backstop. Quantized for backend-name stability (see + // effective_transport_timeout_ms). let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + let mediator_timeout = + effective_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); - if remaining_ms == 0 { + if mediator_timeout == 0 { log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); let winning = self.select_winning_bids(&provider_responses, &floor_prices); return Ok(OrchestrationResult { @@ -297,9 +359,7 @@ impl AuctionOrchestrator { let mediator_context = AuctionContext { settings: context.settings, request: context.request, - // Bound by both the remaining auction budget and the mediator's - // own configured timeout, matching the dispatched collect path. - timeout_ms: remaining_ms.min(mediator.timeout_ms()), + timeout_ms: mediator_timeout, provider_responses: Some(&provider_responses), services: context.services, }; @@ -465,10 +525,11 @@ impl AuctionOrchestrator { // Give each provider only the remaining time from the auction // deadline so that backend transport timeouts do not extend past - // the overall budget. Also respect the provider's own configured - // timeout when it is tighter than the remaining budget. + // the overall budget, quantized for backend-name stability (see + // effective_transport_timeout_ms). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = remaining_ms.min(provider.timeout_ms()); + let effective_timeout = + effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); @@ -876,8 +937,11 @@ impl AuctionOrchestrator { continue; } + // Remaining budget quantized for backend-name stability (see + // effective_transport_timeout_ms). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = remaining_ms.min(provider.timeout_ms()); + let effective_timeout = + effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!( @@ -1132,8 +1196,15 @@ impl AuctionOrchestrator { // timeout) at dispatch time, so they cannot run past A_deadline // independently. Giving the mediator an uncapped timeout lets it run // past A_deadline, violating the bounded hold invariant. + // The mediator's only time bound on this path is its + // backend transport timeout, so the effective value must + // never exceed the remaining budget. Quantized for + // backend-name stability (see + // effective_transport_timeout_ms). let remaining = remaining_budget_ms(auction_start, timeout_ms); - if remaining == 0 { + let mediator_timeout = + effective_transport_timeout_ms(remaining, mediator.timeout_ms()); + if mediator_timeout == 0 { log::warn!( "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", mediator.provider_name(), @@ -1148,7 +1219,6 @@ impl AuctionOrchestrator { metadata: HashMap::new(), }; } - let mediator_timeout = remaining.min(mediator.timeout_ms()); let mediator_start = Instant::now(); log::info!( "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", @@ -1334,7 +1404,7 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; use std::collections::{HashMap, HashSet}; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use super::AuctionOrchestrator; @@ -1342,9 +1412,49 @@ mod tests { // Minimal test double for AuctionProvider // --------------------------------------------------------------------------- + /// Minimal stub provider. Optionally records every transport timeout it + /// observes — the value passed to `backend_name` and the + /// `context.timeout_ms` handed to `request_bids` — so tests can assert + /// the orchestrator quantizes them. struct StubAuctionProvider { name: &'static str, backend: &'static str, + configured_timeout_ms: u32, + observed_timeouts: Option>>>, + } + + impl StubAuctionProvider { + fn new(name: &'static str, backend: &'static str) -> Self { + Self { + name, + backend, + configured_timeout_ms: 2000, + observed_timeouts: None, + } + } + + fn recording( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + observed_timeouts: Arc>>, + ) -> Self { + Self { + name, + backend, + configured_timeout_ms, + observed_timeouts: Some(observed_timeouts), + } + } + + fn record(&self, timeout_ms: u32) { + if let Some(observed) = &self.observed_timeouts { + observed + .lock() + .expect("should lock observed timeouts") + .push(timeout_ms); + } + } } #[async_trait::async_trait(?Send)] @@ -1358,6 +1468,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + self.record(context.timeout_ms); let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") @@ -1389,10 +1500,11 @@ mod tests { } fn timeout_ms(&self) -> u32 { - 2000 + self.configured_timeout_ms } - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { + self.record(timeout_ms); Some(self.backend.to_string()) } } @@ -1509,10 +1621,10 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "bidder", + "bidder-backend", + ))); orchestrator.register_provider(Arc::new(CacheRestoringMediator)); let request = create_test_auction_request(); @@ -1926,6 +2038,438 @@ mod tests { ); } + #[test] + fn quantize_transport_timeout_floors_to_quantum() { + assert_eq!( + super::quantize_transport_timeout_ms(0), + 0, + "should keep zero at zero" + ); + assert_eq!( + super::quantize_transport_timeout_ms(249), + 0, + "should floor a sub-quantum budget to zero" + ); + assert_eq!( + super::quantize_transport_timeout_ms(250), + 250, + "should keep an exact quantum multiple unchanged" + ); + assert_eq!( + super::quantize_transport_timeout_ms(999), + 750, + "should floor to the next-lower quantum multiple" + ); + assert_eq!( + super::quantize_transport_timeout_ms(2000), + 2000, + "should keep a larger exact quantum multiple unchanged" + ); + } + + #[test] + fn effective_transport_timeout_prefers_configured_constant() { + assert_eq!( + super::effective_transport_timeout_ms(2000, 1000), + 1000, + "should use the configured timeout verbatim when the budget allows" + ); + assert_eq!( + super::effective_transport_timeout_ms(2000, 100), + 100, + "should preserve a sub-quantum configured timeout — quantizing it away would permanently disable the provider" + ); + assert_eq!( + super::effective_transport_timeout_ms(999, 2000), + 750, + "should quantize the budget-bound value down to the 750ms bucket" + ); + assert_eq!( + super::effective_transport_timeout_ms(300, 2000), + 250, + "should quantize a tight budget down to one quantum" + ); + assert_eq!( + super::effective_transport_timeout_ms(200, 2000), + 200, + "should pass a sub-quantum budget through exactly instead of rounding to zero" + ); + assert_eq!( + super::effective_transport_timeout_ms(50, 100), + 50, + "should pass through when the budget is below both the quantum and the configured timeout" + ); + assert_eq!( + super::effective_transport_timeout_ms(0, 1000), + 0, + "should return zero for an exhausted budget so the launch is skipped" + ); + assert_eq!( + super::effective_transport_timeout_ms(100, 0), + 0, + "should return zero for a zero configured timeout so the launch is skipped" + ); + } + + #[test] + fn sub_quantum_configured_timeout_still_launches_provider() { + futures::executor::block_on(async { + // A provider whose configured timeout is below one quantum must + // still launch with its exact configured value: the constant is + // name-stable on its own, so only budget-derived values are + // quantized. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 100, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction"); + + let observed = observed.lock().expect("should lock observed timeouts"); + assert!( + !observed.is_empty(), + "should launch the sub-quantum-configured provider" + ); + for timeout in observed.iter() { + assert_eq!( + *timeout, 100, + "should pass the configured 100ms timeout through unchanged" + ); + } + }); + } + + #[test] + fn parallel_path_quantizes_provider_transport_timeout() { + futures::executor::block_on(async { + // A 999ms budget must reach the provider as the 750ms quantum + // bucket — both in backend_name (which derives the Fastly backend + // name) and in context.timeout_ms (which configures the backend + // and payload deadlines) — so the backend name stays stable + // across requests with slightly different remaining budgets. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 999, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 2000, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 999, + provider_responses: None, + services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction"); + + let observed = observed.lock().expect("should lock observed timeouts"); + assert!( + !observed.is_empty(), + "should record provider transport timeouts" + ); + for timeout in observed.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + && *timeout > 0 + && *timeout <= 750, + "should floor the 999ms budget to a quantum bucket at or below 750ms, got {timeout}ms" + ); + } + }); + } + + #[test] + fn sub_quantum_budget_launches_with_exact_remaining_timeout() { + futures::executor::block_on(async { + // A configured auction budget below one quantum must still launch + // providers with the exact remaining budget — rounding it to zero + // would hard-fail every auction for publishers with sub-250ms + // budgets. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 200, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 2000, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 200, + provider_responses: None, + services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction with a sub-quantum budget"); + + assert_eq!( + result.provider_responses.len(), + 1, + "should launch the provider despite the sub-quantum budget" + ); + let observed = observed.lock().expect("should lock observed timeouts"); + assert!( + !observed.is_empty(), + "should record provider transport timeouts" + ); + for timeout in observed.iter() { + assert!( + *timeout > 0 && *timeout <= 200, + "should pass the exact sub-quantum remaining budget through, got {timeout}ms" + ); + } + }); + } + + #[test] + fn synchronous_mediation_quantizes_mediator_timeout() { + futures::executor::block_on(async { + // The mediator has no select-loop deadline backstop, so its + // transport timeout must be quantized by rounding down: a + // quantum-aligned value no larger than the remaining budget. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + mediator: Some("mediator".to_string()), + timeout_ms: 999, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "bidder", + "bidder-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "mediator", + "mediator-backend", + 2000, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 999, + provider_responses: None, + services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete mediated auction"); + + let observed = observed.lock().expect("should lock observed timeouts"); + assert!(!observed.is_empty(), "should run the mediator"); + for timeout in observed.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, + "mediator timeout {timeout}ms should be quantum-aligned" + ); + assert!( + *timeout > 0 && *timeout <= 750, + "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" + ); + } + }); + } + + #[test] + fn dispatched_collect_quantizes_mediator_timeout() { + futures::executor::block_on(async { + // Same invariant as the synchronous path, on the split + // dispatch/collect path used by publisher page rendering. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed_bidder = Arc::new(Mutex::new(Vec::new())); + let observed_mediator = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + mediator: Some("mediator".to_string()), + timeout_ms: 999, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 2000, + Arc::clone(&observed_bidder), + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "mediator", + "mediator-backend", + 2000, + Arc::clone(&observed_mediator), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 999, + provider_responses: None, + services, + }; + + let dispatched = match orchestrator.dispatch_auction(&request, &context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + _ => panic!("should dispatch the bidder request"), + }; + orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + + let observed_bidder = observed_bidder.lock().expect("should lock bidder timeouts"); + assert!( + !observed_bidder.is_empty(), + "should record dispatched bidder timeouts" + ); + for timeout in observed_bidder.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + && *timeout > 0 + && *timeout <= 750, + "dispatched bidder timeout should floor 999ms to a quantum bucket at or below 750ms, got {timeout}ms" + ); + } + + let observed_mediator = observed_mediator + .lock() + .expect("should lock mediator timeouts"); + assert!(!observed_mediator.is_empty(), "should run the mediator"); + for timeout in observed_mediator.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, + "mediator timeout {timeout}ms should be quantum-aligned" + ); + assert!( + *timeout > 0 && *timeout <= 750, + "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" + ); + } + }); + } + #[test] fn select_error_is_attributed_to_correct_provider() { futures::executor::block_on(async { @@ -1950,14 +2494,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -2033,14 +2577,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -2098,14 +2642,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); From 35e872bf621962c814d13866fac26b5c75f4d44c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 20:56:59 +0530 Subject: [PATCH 002/494] Stream publisher origin bodies end-to-end on Fastly Publisher pages were fully buffered before the first byte reached the client: the platform client materialized the origin body (10 MiB cap), the rewrite pipeline ran over an in-memory cursor, and the EdgeZero finalize buffered the assembled response while awaiting auction collection. TTFB therefore tracked full origin transfer plus the auction instead of origin first byte. - Add supports_streaming_responses() to PlatformHttpClient (default false, Fastly true) and request with_stream_response() on the publisher origin fetch only where honored - Teach the pipeline to consume Body::Stream asynchronously: BodyChunkSource (cumulative raw-byte cap via publisher.max_buffered_body_bytes), push-style BodyStreamDecoder/BodyStreamEncoder in streaming_processor - Replace the Fastly buffered finalize with publisher_response_into_streaming_response: a lazy Body::Stream that commits headers at origin first byte, streams rewritten chunks, and holds only the tail for auction collection; bids still inject before body close - Share one hold implementation (hold_step_decoded_chunk / hold_finish_segments) between the lazy body and the writer-driven loop so the paths cannot drift; collect_non_html_auction dedupes the collect-before-stream path - Finalize brotli decode with close() so truncated origin streams error instead of silently truncating; decode failures emit stream_decode_error telemetry - Guard bodiless (HEAD/204/304) responses and log wasted auction dispatch, matching the buffered finalizer Local A/B on a 183 KB gzip publisher page with a live 3-slot auction (release builds, 20 interleaved rounds): TTFB median 741 ms buffered vs 161 ms streamed (-78%); guest wall time and wasm heap unchanged. --- Cargo.lock | 1 + Cargo.toml | 1 + .../trusted-server-adapter-fastly/src/app.rs | 43 +- .../trusted-server-adapter-fastly/src/main.rs | 11 +- .../src/platform.rs | 4 + crates/trusted-server-core/Cargo.toml | 1 + .../trusted-server-core/src/platform/http.rs | 11 + .../src/platform/test_support.rs | 15 + crates/trusted-server-core/src/proxy.rs | 9 +- crates/trusted-server-core/src/publisher.rs | 1915 +++++++++++++++-- crates/trusted-server-core/src/settings.rs | 15 +- .../src/streaming_processor.rs | 191 ++ ...2026-07-08-true-origin-streaming-fastly.md | 1039 +++++++++ 13 files changed, 3078 insertions(+), 178 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md diff --git a/Cargo.lock b/Cargo.lock index a19be7abb..cd2227882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5277,6 +5277,7 @@ dependencies = [ name = "trusted-server-core" version = "0.1.0" dependencies = [ + "async-stream", "async-trait", "base64", "brotli", diff --git a/Cargo.toml b/Cargo.toml index 27411acbd..0512371e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ debug = 1 [workspace.dependencies] anyhow = "1" +async-stream = "0.3" async-trait = "0.1" axum = "0.8" base64 = "0.22" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e56498b10..d5e37f91a 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -65,10 +65,10 @@ //! run on these responses. Legacy ran EC finalization on its own auth //! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC //! cookies are issued to unauthenticated callers. -//! - **Publisher responses** are buffered (bounded by -//! `publisher.max_buffered_body_bytes`) instead of streamed to the client. -//! Asset responses are streamed straight to the client (see -//! [`dispatch_asset_fallback`]), matching legacy. +//! - **Publisher responses** keep Fastly origin bodies streaming through the +//! `EdgeZero` response body when the body is processable or pass-through. +//! Adapters without streaming-body support still use the bounded buffered +//! finalizer. //! - **Router-level 405s** (unregistered verbs) skip EC finalization along //! with the middleware chain; the entry point still adds TS headers. //! @@ -116,8 +116,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy, }; use trusted_server_core::publisher::{ - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + publisher_response_into_streaming_response, AuctionDispatch, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -721,10 +721,9 @@ async fn dispatch_fallback( let result = if uses_dynamic_tsjs_fallback(&method, &path) { handle_tsjs_dynamic(&req, &state.registry) } else if state.registry.has_route(&method, &path) { - // Integration-proxy responses are not bounded by publisher.max_buffered_body_bytes. - // Only the handle_publisher_request branch below routes through - // buffer_publisher_response_async. Integration responses are small in practice - // and the EdgeZero flag is off by default; extend the cap here if that changes. + // Integration-proxy responses are not bounded by + // publisher.max_buffered_body_bytes. Publisher fallback below uses the + // publisher-specific streaming finalizer instead. state .registry .handle_proxy(ProxyDispatchInput { @@ -773,9 +772,8 @@ async fn dispatch_fallback( match runtime_services_for_consent_route(&state.settings, services) { Ok(publisher_services) => { // Run the server-side auction with the configured creative- - // opportunity slots and collect the dispatched bids in the - // buffered finalize (`buffer_publisher_response_async`), matching - // the legacy streaming path. `handle_publisher_request` matches the + // opportunity slots and collect dispatched bids from the lazy + // publisher body stream. `handle_publisher_request` matches the // slots against the request path. The partner registry plus the // EC identity-graph KV (`ec.kv_graph`) enrich the bid request with // server-side EIDs, same as the legacy auction. @@ -797,17 +795,14 @@ async fn dispatch_fallback( ) .await { - Ok(pub_response) => { - buffer_publisher_response_async( - pub_response, - &method, - &state.settings, - &state.registry, - &state.orchestrator, - &publisher_services, - ) - .await - } + Ok(pub_response) => publisher_response_into_streaming_response( + pub_response, + &method, + Arc::clone(&state.settings), + state.registry.as_ref(), + Arc::clone(&state.orchestrator), + publisher_services.clone(), + ), Err(e) => Err(e), } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d20de533d..963686cb8 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -321,10 +321,9 @@ fn run_edgezero_pull_sync_after_send( /// Sends a finalized `EdgeZero` response to the client. /// -/// Asset streams commit headers first, then pipe the origin body chunk by chunk -/// so large responses do not materialize in the Wasm heap. Publisher responses -/// are buffered by the server-side auction path so bids can be injected into the -/// document, and are sent in one shot along with all other responses. +/// Streaming `EdgeZero` bodies commit headers first, then pipe chunks to Fastly's +/// client stream so large asset and publisher-origin responses do not +/// materialize in the Wasm heap. fn send_edgezero_response( mut response: HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, @@ -350,11 +349,11 @@ fn send_edgezero_response( match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) { Ok(()) => { if let Err(e) = streaming_body.finish() { - log::error!("failed to finish EdgeZero asset streaming body: {e}"); + log::error!("failed to finish EdgeZero streaming body: {e}"); } } Err(e) => { - log::error!("EdgeZero asset streaming failed: {e:?}"); + log::error!("EdgeZero streaming failed: {e:?}"); drop(streaming_body); } } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 106ced787..65d0f4b0d 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -426,6 +426,10 @@ pub struct FastlyPlatformHttpClient; #[async_trait::async_trait(?Send)] impl PlatformHttpClient for FastlyPlatformHttpClient { + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send( &self, request: PlatformHttpRequest, diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index ba88f361f..bedefc327 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] async-trait = { workspace = true } +async-stream = { workspace = true } base64 = { workspace = true } brotli = { workspace = true } bytes = { workspace = true } diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index 80bb23121..9e9337edd 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -276,6 +276,17 @@ pub trait PlatformHttpClient: Send + Sync { true } + /// Whether [`send`](Self::send) can preserve upstream response bodies as + /// [`Body::Stream`](edgezero_core::body::Body::Stream) when requested via + /// [`PlatformHttpRequest::with_stream_response`]. + /// + /// Adapters that cannot preserve streaming response bodies must keep the + /// default `false` so callers do not request a contract the adapter will + /// reject or silently buffer. + fn supports_streaming_responses(&self) -> bool { + false + } + /// Wait for one of the in-flight requests to complete. /// /// # Errors diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ee7201fb8..0c86b9594 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -224,6 +224,9 @@ pub(crate) struct StubHttpClient { // Reported by supports_concurrent_fanout(); set false to emulate // platforms whose send_async executes eagerly (e.g. Cloudflare Workers). concurrent_fanout: std::sync::atomic::AtomicBool, + // Reported by supports_streaming_responses(); set true to emulate Fastly's + // streaming response support. + streaming_responses_supported: std::sync::atomic::AtomicBool, image_optimizer_options: Mutex>>, stream_response_flags: Mutex>, request_methods: Mutex>, @@ -246,6 +249,7 @@ impl StubHttpClient { request_headers: Mutex::new(Vec::new()), select_errors: Mutex::new(VecDeque::new()), concurrent_fanout: std::sync::atomic::AtomicBool::new(true), + streaming_responses_supported: std::sync::atomic::AtomicBool::new(false), image_optimizer_options: Mutex::new(Vec::new()), stream_response_flags: Mutex::new(Vec::new()), request_methods: Mutex::new(Vec::new()), @@ -260,6 +264,12 @@ impl StubHttpClient { .store(supported, std::sync::atomic::Ordering::Relaxed); } + /// Make `supports_streaming_responses()` report the given value. + pub fn set_streaming_responses_supported(&self, supported: bool) { + self.streaming_responses_supported + .store(supported, std::sync::atomic::Ordering::Relaxed); + } + /// Queue a canned response by status code and body bytes. pub fn push_response(&self, status: u16, body: Vec) { self.push_response_with_headers(status, body, Vec::<(String, String)>::new()); @@ -363,6 +373,11 @@ impl PlatformHttpClient for StubHttpClient { .load(std::sync::atomic::Ordering::Relaxed) } + fn supports_streaming_responses(&self) -> bool { + self.streaming_responses_supported + .load(std::sync::atomic::Ordering::Relaxed) + } + async fn send( &self, request: PlatformHttpRequest, diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 19c10a80d..00444b384 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -246,7 +246,10 @@ fn platform_response_to_fastly_asset(platform_resp: PlatformResponse) -> AssetPr } } -/// Stream an asset response body directly to a writable client stream. +/// Stream a platform response body directly to a writable client stream. +/// +/// Asset routes and Fastly `EdgeZero` publisher fallback both use this bridge +/// after headers have been committed through `stream_to_client()`. /// /// # Errors /// @@ -261,7 +264,7 @@ pub async fn stream_asset_body( output .write_all(bytes.as_ref()) .change_context(TrustedServerError::Proxy { - message: "failed to write buffered asset response body".to_string(), + message: "failed to write buffered platform response body".to_string(), })?; } EdgeBody::Stream(mut stream) => { @@ -274,7 +277,7 @@ pub async fn stream_asset_body( output .write_all(chunk.as_ref()) .change_context(TrustedServerError::Proxy { - message: "failed to write streaming asset response body".to_string(), + message: "failed to write streaming platform response body".to_string(), })?; } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5d..c10ac0b39 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -22,9 +22,15 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Duration; +use brotli::enc::writer::CompressorWriter; +use brotli::enc::BrotliEncoderParams; +use brotli::Decompressor; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; +use flate2::read::{GzDecoder, ZlibDecoder}; +use flate2::write::{GzEncoder, ZlibEncoder}; +use futures::StreamExt as _; use http::{header, HeaderValue, Method, Request, Response, StatusCode, Uri}; use crate::auction::endpoints::{ @@ -53,19 +59,134 @@ use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, Runtime use crate::price_bucket::{price_bucket, PriceGranularity}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; -use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; +use crate::streaming_processor::{ + BodyStreamDecoder, BodyStreamEncoder, Compression, PipelineConfig, StreamProcessor, + StreamingPipeline, STREAM_CHUNK_SIZE, +}; use crate::streaming_replacer::create_url_replacer; const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); -/// Read buffer size for streaming body processing and brotli internal buffers. -/// Both the `Decompressor` and `CompressorWriter` use this value so all -/// brotli I/O layers operate on consistently-sized chunks. -const STREAM_CHUNK_SIZE: usize = 8192; +fn body_as_reader( + body: EdgeBody, +) -> Result, Report> { + let bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "streaming body cannot be processed by sync publisher pipeline".to_string(), + }) + })?; + Ok(std::io::Cursor::new(bytes)) +} + +struct BodyChunkSource { + body: Option, + chunk_size: usize, + max_bytes: usize, + bytes_seen: usize, + once_offset: usize, +} + +impl BodyChunkSource { + fn new(body: EdgeBody, chunk_size: usize) -> Self { + Self { + body: Some(body), + chunk_size, + max_bytes: usize::MAX, + bytes_seen: 0, + once_offset: 0, + } + } + + fn with_max_bytes(mut self, max_bytes: usize) -> Self { + self.max_bytes = max_bytes; + self + } + + async fn next_chunk(&mut self) -> Result, Report> { + let Some(body) = self.body.take() else { + return Ok(None); + }; + + let chunk = match body { + EdgeBody::Once(bytes) => { + if self.once_offset >= bytes.len() { + None + } else { + let end = (self.once_offset + self.chunk_size).min(bytes.len()); + let chunk = bytes.slice(self.once_offset..end); + self.once_offset = end; + if self.once_offset < bytes.len() { + self.body = Some(EdgeBody::Once(bytes)); + } + Some(chunk) + } + } + EdgeBody::Stream(mut stream) => match stream.next().await { + Some(Ok(chunk)) => { + self.body = Some(EdgeBody::Stream(stream)); + Some(chunk) + } + Some(Err(err)) => { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to read publisher origin body stream: {err}"), + })); + } + None => None, + }, + }; + + let Some(chunk) = chunk else { + return Ok(None); + }; + + self.bytes_seen = self.bytes_seen.checked_add(chunk.len()).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin body byte count overflowed".to_string(), + }) + })?; + if self.bytes_seen > self.max_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "publisher origin body exceeded {}-byte streaming limit", + self.max_bytes + ), + })); + } + + Ok(Some(chunk)) + } +} + +fn process_and_encode_chunk( + processor: &mut P, + encoder: &mut BodyStreamEncoder, + chunk: &[u8], + is_last: bool, + process_error: &str, +) -> Result, Report> { + let processed = + processor + .process_chunk(chunk, is_last) + .change_context(TrustedServerError::Proxy { + message: process_error.to_string(), + })?; + if processed.is_empty() { + return Ok(None); + } + let encoded = encoder.encode_chunk(&processed)?; + if encoded.is_empty() { + return Ok(None); + } + Ok(Some(bytes::Bytes::from(encoded))) +} -fn body_as_reader(body: EdgeBody) -> std::io::Cursor { - std::io::Cursor::new(body.into_bytes().unwrap_or_default()) +fn publisher_stream_error(err: Report) -> std::io::Error { + let message = format!("{err:?}"); + // Consume the report so clippy's needless_pass_by_value accepts the + // by-value signature that `map_err(publisher_stream_error)` requires. + drop(err); + std::io::Error::other(message) } fn not_found_response() -> Response { @@ -233,6 +354,55 @@ struct ProcessResponseParams<'a> { ad_bids_state: &'a Arc>>, } +struct PublisherBodyProcessor { + inner: Box, +} + +impl PublisherBodyProcessor { + fn new( + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + ) -> Result> { + let is_html = is_html_content_type(¶ms.content_type); + 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, + settings, + integration_registry, + params.ad_slots_script.as_deref().map(str::to_string), + Arc::clone(¶ms.ad_bids_state), + )?) + } else if is_rsc_flight { + Box::new(RscFlightUrlRewriter::new( + ¶ms.origin_host, + ¶ms.origin_url, + ¶ms.request_host, + ¶ms.request_scheme, + )) + } else { + Box::new(create_url_replacer( + ¶ms.origin_host, + ¶ms.origin_url, + ¶ms.request_host, + ¶ms.request_scheme, + )) + }; + + Ok(Self { inner }) + } +} + +impl StreamProcessor for PublisherBodyProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, std::io::Error> { + self.inner.process_chunk(chunk, is_last) + } +} + /// Process response body through the streaming pipeline. /// /// Selects the appropriate processor based on content type (HTML rewriter, @@ -276,7 +446,7 @@ fn process_response_streaming( params.ad_slots_script.map(str::to_string), params.ad_bids_state.clone(), )?; - StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, processor).process(body_as_reader(body)?, output)?; } else if is_rsc_flight { // RSC Flight responses are length-prefixed (T rows). A naive string replacement will // corrupt the stream by changing byte lengths without updating the prefixes. @@ -286,7 +456,7 @@ fn process_response_streaming( params.request_host, params.request_scheme, ); - StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, processor).process(body_as_reader(body)?, output)?; } else { let replacer = create_url_replacer( params.origin_host, @@ -294,12 +464,352 @@ fn process_response_streaming( params.request_host, params.request_scheme, ); - StreamingPipeline::new(config, replacer).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, replacer).process(body_as_reader(body)?, output)?; } Ok(()) } +async fn process_response_streaming_async( + body: EdgeBody, + output: &mut W, + params: &ProcessResponseParams<'_>, + max_raw_body_bytes: usize, +) -> Result<(), Report> { + let is_html = is_html_content_type(params.content_type); + let is_rsc_flight = + content_type_contains_ascii_case_insensitive(params.content_type, "text/x-component"); + log::debug!( + "process_response_streaming_async: content_type={}, content_encoding={}, is_html={}, is_rsc_flight={}", + params.content_type, + params.content_encoding, + is_html, + is_rsc_flight + ); + + let compression = Compression::from_content_encoding(params.content_encoding); + + if is_html { + let mut 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(), + )?; + process_body_chunks_async( + body, + output, + &mut processor, + compression, + max_raw_body_bytes, + ) + .await + } else if is_rsc_flight { + let mut processor = RscFlightUrlRewriter::new( + params.origin_host, + params.origin_url, + params.request_host, + params.request_scheme, + ); + process_body_chunks_async( + body, + output, + &mut processor, + compression, + max_raw_body_bytes, + ) + .await + } else { + let mut replacer = create_url_replacer( + params.origin_host, + params.origin_url, + params.request_host, + params.request_scheme, + ); + process_body_chunks_async(body, output, &mut replacer, compression, max_raw_body_bytes) + .await + } +} + +async fn process_body_chunks_async( + body: EdgeBody, + writer: &mut W, + processor: &mut P, + compression: Compression, + max_raw_body_bytes: usize, +) -> Result<(), Report> { + let mut decoder = BodyStreamDecoder::new(compression); + let mut encoder = BodyStreamEncoder::new(compression); + let mut source = + BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); + + while let Some(chunk) = source.next_chunk().await? { + let decoded = decoder.decode_chunk(&chunk)?; + if decoded.is_empty() { + continue; + } + if let Some(encoded) = process_and_encode_chunk( + processor, + &mut encoder, + &decoded, + false, + "Failed to process chunk", + )? { + write_encoded_segment(writer, &encoded)?; + } + } + + for encoded in passthrough_finish_segments(processor, &mut decoder, &mut encoder)? { + write_encoded_segment(writer, &encoded)?; + } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + + Ok(()) +} + +/// Write one encoded output segment produced by the chunk pipeline. +fn write_encoded_segment( + writer: &mut W, + encoded: &[u8], +) -> Result<(), Report> { + writer + .write_all(encoded) + .change_context(TrustedServerError::Proxy { + message: "Failed to write encoded chunk".to_string(), + }) +} + +/// Finalize a no-hold chunk pipeline: drain the decoder tail through the +/// processor, signal end-of-stream to the processor, and emit the encoder +/// trailer. Returns the encoded segments for the caller to emit. +fn passthrough_finish_segments( + processor: &mut P, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, +) -> Result, Report> { + let mut segments = Vec::new(); + let decoded_tail = decoder.finish()?; + if !decoded_tail.is_empty() { + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &decoded_tail, + false, + "Failed to process decoded tail", + )? { + segments.push(encoded); + } + } + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &[], + true, + "Failed to finalize processor", + )? { + segments.push(encoded); + } + let trailer = encoder.finish()?; + if !trailer.is_empty() { + segments.push(bytes::Bytes::from(trailer)); + } + Ok(segments) +} + +/// Mutable auction-hold state threaded through the streaming hold pipeline. +struct AuctionHoldState { + hold: Option, + dispatched: Option, + telemetry: AuctionTelemetryCarry, +} + +impl AuctionHoldState { + fn new(dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry) -> Self { + Self { + hold: Some(BodyCloseHoldBuffer::new()), + dispatched: Some(dispatched), + telemetry, + } + } +} + +/// Abandon the in-flight auction (if still pending) with the given telemetry +/// reason. No-op once the auction has been collected or already abandoned. +async fn abandon_hold_auction( + state: &mut AuctionHoldState, + services: &RuntimeServices, + reason: &'static str, +) { + if let Some(dispatched) = state.dispatched.take() { + emit_abandoned_auction( + services, + state.telemetry.observation.take(), + dispatched, + reason, + ) + .await; + } +} + +/// Feed one decoded chunk through the close-body hold and processor. +/// +/// Returns the encoded output segments for the caller to emit — written to a +/// client stream by [`body_close_hold_loop_stream`], yielded from the lazy +/// body by [`publisher_response_into_streaming_response`]. Both async hold +/// paths share this function so their behavior cannot drift apart. +/// +/// When the raw `( + processor: &mut P, + encoder: &mut BodyStreamEncoder, + chunk: &[u8], + state: &mut AuctionHoldState, + collect_refs: &AuctionHoldCollectRefs<'_>, +) -> Result, Report> { + let mut segments = Vec::new(); + if let Some(hold_buffer) = state.hold.as_mut() { + let ready = hold_buffer.push(chunk); + match process_and_encode_chunk(processor, encoder, &ready, false, "Failed to process chunk") + { + Ok(Some(encoded)) => segments.push(encoded), + Ok(None) => {} + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + } + + if state + .hold + .as_ref() + .is_some_and(BodyCloseHoldBuffer::found_close) + { + let dispatched = state + .dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction( + dispatched, + state.telemetry.take(), + collect_refs.price_granularity, + collect_refs.ad_bids_state, + collect_refs.orchestrator, + collect_refs.services, + collect_refs.settings, + ) + .await; + + let held = state + .hold + .take() + .expect("should have close-body hold buffer") + .finish(); + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &held, + false, + "Failed to process held body close", + )? { + segments.push(encoded); + } + } + } else { + match process_and_encode_chunk(processor, encoder, chunk, false, "Failed to process chunk") + { + Ok(Some(encoded)) => segments.push(encoded), + Ok(None) => {} + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + } + } + Ok(segments) +} + +/// Finalize the close-body hold pipeline at end of the origin stream. +/// +/// Drains the decoder tail through the hold (or straight through when the +/// hold was already released mid-stream), collects the auction if the +/// close-body tag never streamed, processes the held tail plus the +/// processor's final chunk, and emits the encoder trailer. Returns the +/// encoded segments for the caller to emit. On decoder failure the pending +/// auction is abandoned before the error is returned. +async fn hold_finish_segments( + processor: &mut P, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, + state: &mut AuctionHoldState, + collect_refs: &AuctionHoldCollectRefs<'_>, +) -> Result, Report> { + let mut segments = Vec::new(); + + let decoded_tail = match decoder.finish() { + Ok(decoded_tail) => decoded_tail, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; + return Err(err); + } + }; + if !decoded_tail.is_empty() { + segments.extend( + hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?, + ); + } + + if let Some(hold) = state.hold.take() { + let dispatched = state + .dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction( + dispatched, + state.telemetry.take(), + collect_refs.price_granularity, + collect_refs.ad_bids_state, + collect_refs.orchestrator, + collect_refs.services, + collect_refs.settings, + ) + .await; + + let held = hold.finish(); + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &held, + false, + "Failed to process held body close", + )? { + segments.push(encoded); + } + } + + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &[], + true, + "Failed to finalize processor", + )? { + segments.push(encoded); + } + let trailer = encoder.finish()?; + if !trailer.is_empty() { + segments.push(bytes::Bytes::from(trailer)); + } + Ok(segments) +} + /// Create a unified HTML stream processor. /// /// Builds the config via [`HtmlProcessorConfig::from_settings`] and then @@ -339,16 +849,13 @@ pub enum PublisherResponse { /// content on any status (2xx or non-2xx — e.g., branded 404/500 HTML and /// error JSON still get URL rewriting) where the encoding is supported. /// Post-processors run inside the streaming processor, so processable HTML - /// is streamed regardless of whether any are registered. The caller must: - /// 1. Call `finalize_response()` on the response - /// 2. Call `response.stream_to_client()` to get a `StreamingBody` - /// 3. Call `stream_publisher_body()` with the body and streaming writer - /// 4. Call `StreamingBody::finish()` + /// is streamed regardless of whether any are registered. /// - /// **Interim (PR 15):** `body` has already been fully materialised into - /// WASM heap by the platform HTTP client. `stream_publisher_body` reads - /// from an in-memory buffer, not a live origin stream. The origin-side - /// peak is bounded by `MAX_PLATFORM_RESPONSE_BODY_BYTES`. + /// Adapters with platform streaming support preserve `body` as + /// [`EdgeBody::Stream`] and attach a lazy processed stream via + /// [`publisher_response_into_streaming_response`]. Buffered adapters use + /// [`buffer_publisher_response_async`] and are bounded by + /// `settings.publisher.max_buffered_body_bytes`. Stream { /// Response with all headers set (EC ID, cookies, etc.) /// but body not yet written. `Content-Length` already removed. @@ -363,12 +870,9 @@ pub enum PublisherResponse { /// `finalize_response()` and `send_to_client()` are applied at the outer /// response-dispatch level, not in this arm. /// - /// `Content-Length` is preserved — the body is unmodified. - /// - /// **Interim (PR 15):** `body` has been fully materialised into WASM heap. - /// Previously, binary assets streamed lazily from origin with no WASM - /// buffering. This path is now bounded by `MAX_PLATFORM_RESPONSE_BODY_BYTES`; - /// assets exceeding that limit return an error instead of exhausting heap. + /// `Content-Length` is preserved — the body is unmodified. Streaming + /// adapters reattach the origin body directly so non-processable 2xx bodies + /// can pass through without materializing in WASM memory. PassThrough { /// Response with all headers set but body not yet written. response: Response, @@ -465,7 +969,7 @@ pub struct OwnedProcessResponseParams { /// statuses (204, 304) carry no body but may advertise the `GET` representation's /// length, so they skip the buffer and length rewrite. /// -/// Every adapter (Axum, Cloudflare, Spin, and the Fastly `EdgeZero` path) calls +/// Buffered adapters (Axum, Cloudflare, Spin, and non-streaming fallbacks) call /// this: it drives /// [`stream_publisher_body_async`], which awaits /// [`AuctionOrchestrator::collect_dispatched_auction`], writes the winning bids @@ -530,48 +1034,225 @@ pub async fn buffer_publisher_response_async( } } -/// Returns `true` when a buffered publisher response should carry a body and a -/// recomputed `Content-Length`. +/// Convert a [`PublisherResponse`] into a response that preserves streaming +/// bodies where possible. /// -/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting -/// their `Content-Length` to the (empty) buffered length would mislead clients -/// and caches, so the origin metadata is preserved instead. -fn response_carries_body(method: &Method, status: StatusCode) -> bool { - *method != Method::HEAD - && status != StatusCode::NO_CONTENT - && status != StatusCode::NOT_MODIFIED -} - -/// A [`Write`] sink that buffers into a `Vec` but fails once the configured -/// byte limit would be exceeded. +/// Buffered adapters should keep using [`buffer_publisher_response_async`]. +/// Fastly uses this helper before the entry point commits headers, allowing the +/// response body to be pulled lazily by `stream_to_client()`. /// -/// Used to bound in-WASM-heap buffering of decoded/re-written publisher bodies. -/// A highly-compressible origin response can sit under the platform raw-body cap -/// yet expand past a safe heap size after decode and post-processing; this writer -/// turns that into a recoverable error instead of an out-of-memory abort. -pub struct BoundedWriter { - inner: Vec, - limit: usize, -} - -impl BoundedWriter { - /// Creates a writer that accepts at most `limit` bytes before erroring. - #[must_use] - pub fn new(limit: usize) -> Self { - Self { - inner: Vec::new(), - limit, +/// # Errors +/// +/// Returns an error if processor construction fails before the streaming body is +/// created. +pub fn publisher_response_into_streaming_response( + publisher_response: PublisherResponse, + method: &Method, + settings: Arc, + integration_registry: &IntegrationRegistry, + orchestrator: Arc, + services: RuntimeServices, +) -> Result, Report> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::PassThrough { mut response, body } => { + if response_carries_body(method, response.status()) { + *response.body_mut() = body; + } + Ok(response) } - } - - /// Consumes the writer and returns the buffered bytes. - #[must_use] - pub fn into_inner(self) -> Vec { - self.inner - } -} - -impl Write for BoundedWriter { + PublisherResponse::Stream { + mut response, + body, + params, + } => { + if !response_carries_body(method, response.status()) { + if params.dispatched_auction.is_some() { + // A bodiless response (HEAD navigation, 204/304) has no + // `` to inject bids into, so the dispatched SSP + // requests are wasted — surface it for quota observability, + // matching the buffered finalizer. + log::warn!( + "Server-side auction dispatched but response is bodiless (method: {}, status: {}); in-flight SSP bid requests will not be collected", + method, + response.status(), + ); + } + return Ok(response); + } + + response.headers_mut().remove(header::CONTENT_LENGTH); + let mut params = *params; + let mut processor = + PublisherBodyProcessor::new(¶ms, &settings, integration_registry)?; + let stream = async_stream::try_stream! { + let compression = Compression::from_content_encoding(¶ms.content_encoding); + let mut decoder = BodyStreamDecoder::new(compression); + let mut encoder = BodyStreamEncoder::new(compression); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) + .with_max_bytes(settings.publisher.max_buffered_body_bytes); + + // HTML rides the close-body hold so bids land before ``; + // non-HTML has no injection point, so its auction is collected + // before any byte streams (matching the buffered finalizer). + let mut hold_auction = None; + if let Some(dispatched) = params.dispatched_auction.take() { + let telemetry = AuctionTelemetryCarry { + observation: params.auction_observation.take(), + auction_request: params.auction_request.take(), + }; + if is_html_content_type(¶ms.content_type) { + hold_auction = Some((dispatched, telemetry)); + } else { + collect_non_html_auction( + dispatched, + telemetry, + ¶ms, + &orchestrator, + &services, + &settings, + ) + .await; + } + } + + if let Some((dispatched, telemetry)) = hold_auction { + let mut state = AuctionHoldState::new(dispatched, telemetry); + let collect_refs = AuctionHoldCollectRefs { + price_granularity: params.price_granularity, + ad_bids_state: ¶ms.ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + }; + + loop { + let raw_chunk = match source.next_chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(err) => { + abandon_hold_auction(&mut state, &services, "stream_read_error") + .await; + Err(publisher_stream_error(err))?; + unreachable!("error should have returned"); + } + }; + let decoded = match decoder.decode_chunk(&raw_chunk) { + Ok(decoded) => decoded, + Err(err) => { + abandon_hold_auction(&mut state, &services, "stream_decode_error") + .await; + Err(publisher_stream_error(err))?; + unreachable!("error should have returned"); + } + }; + if decoded.is_empty() { + continue; + } + for encoded in hold_step_decoded_chunk( + &mut processor, + &mut encoder, + &decoded, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + yield encoded; + } + } + + for encoded in hold_finish_segments( + &mut processor, + &mut decoder, + &mut encoder, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + yield encoded; + } + } else { + while let Some(raw_chunk) = + source.next_chunk().await.map_err(publisher_stream_error)? + { + let decoded = decoder + .decode_chunk(&raw_chunk) + .map_err(publisher_stream_error)?; + if decoded.is_empty() { + continue; + } + if let Some(encoded) = process_and_encode_chunk( + &mut processor, + &mut encoder, + &decoded, + false, + "Failed to process chunk", + ) + .map_err(publisher_stream_error)? + { + yield encoded; + } + } + for encoded in + passthrough_finish_segments(&mut processor, &mut decoder, &mut encoder) + .map_err(publisher_stream_error)? + { + yield encoded; + } + } + }; + *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + Ok(response) + } + } +} + +/// Returns `true` when a buffered publisher response should carry a body and a +/// recomputed `Content-Length`. +/// +/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting +/// their `Content-Length` to the (empty) buffered length would mislead clients +/// and caches, so the origin metadata is preserved instead. +fn response_carries_body(method: &Method, status: StatusCode) -> bool { + *method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED +} + +/// A [`Write`] sink that buffers into a `Vec` but fails once the configured +/// byte limit would be exceeded. +/// +/// Used to bound in-WASM-heap buffering of decoded/re-written publisher bodies. +/// A highly-compressible origin response can sit under the platform raw-body cap +/// yet expand past a safe heap size after decode and post-processing; this writer +/// turns that into a recoverable error instead of an out-of-memory abort. +pub struct BoundedWriter { + inner: Vec, + limit: usize, +} + +impl BoundedWriter { + /// Creates a writer that accepts at most `limit` bytes before erroring. + #[must_use] + pub fn new(limit: usize) -> Self { + Self { + inner: Vec::new(), + limit, + } + } + + /// Consumes the writer and returns the buffered bytes. + #[must_use] + pub fn into_inner(self) -> Vec { + self.inner + } +} + +impl Write for BoundedWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { if self.inner.len() + buf.len() > self.limit { return Err(std::io::Error::other( @@ -652,7 +1333,29 @@ pub async fn stream_publisher_body_async( services: &RuntimeServices, ) -> Result<(), Report> { let Some(dispatched) = params.dispatched_auction.take() else { - // No auction — use the existing sync pipeline unchanged. + if body.is_stream() { + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + content_type: ¶ms.content_type, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, + }; + return process_response_streaming_async( + body, + output, + &borrowed, + settings.publisher.max_buffered_body_bytes, + ) + .await; + } + + // No auction and already-buffered body — keep the existing sync pipeline. return stream_publisher_body(body, output, params, settings, integration_registry); }; let telemetry = AuctionTelemetryCarry { @@ -665,35 +1368,36 @@ pub async fn stream_publisher_body_async( if !is_html { // Non-HTML: collect auction first, then stream. There is no // to hold, so delaying the entire body until collection is acceptable. - let placeholder = mediator_placeholder_request(); - let result = orchestrator - .collect_dispatched_auction( - dispatched, - services, - &make_collect_context(settings, services, &placeholder), + collect_non_html_auction( + dispatched, + telemetry, + params, + orchestrator, + services, + settings, + ) + .await; + if body.is_stream() { + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + content_type: ¶ms.content_type, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, + }; + return process_response_streaming_async( + body, + output, + &borrowed, + settings.publisher.max_buffered_body_bytes, ) .await; - if let (Some(observation), Some(auction_request)) = - (telemetry.observation, telemetry.auction_request.as_ref()) - { - emit_auction_events_best_effort_lazy(services, || { - build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: auction_request, - result: &result, - }, - ) - }) - .await; } - - write_bids_to_state( - &result.winning_bids, - params.price_granularity, - ¶ms.ad_bids_state, - settings.debug.inject_adm_for_testing, - ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -917,6 +1621,14 @@ struct AuctionCollectCtx<'a> { settings: &'a Settings, } +struct AuctionHoldCollectRefs<'a> { + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, +} + /// Run the close-body hold loop for HTML bodies, collecting the auction before /// the raw `( @@ -926,13 +1638,20 @@ async fn stream_html_with_auction_hold( compression: Compression, ctx: AuctionCollectCtx<'_>, ) -> Result<(), Report> { - use brotli::enc::writer::CompressorWriter; - use brotli::enc::BrotliEncoderParams; - use brotli::Decompressor; - use flate2::read::{GzDecoder, ZlibDecoder}; - use flate2::write::{GzEncoder, ZlibEncoder}; + if body.is_stream() { + let max_raw_body_bytes = ctx.settings.publisher.max_buffered_body_bytes; + return body_close_hold_loop_stream( + body, + output, + processor, + compression, + ctx, + max_raw_body_bytes, + ) + .await; + } - let body = body_as_reader(body); + let body = body_as_reader(body)?; match compression { Compression::None => body_close_hold_loop(body, output, processor, ctx).await, Compression::Gzip => { @@ -969,6 +1688,85 @@ async fn stream_html_with_auction_hold( } } +/// Async-pull variant of [`body_close_hold_loop`] for live origin streams. +/// +/// Shares [`hold_step_decoded_chunk`] and [`hold_finish_segments`] with the +/// lazy streaming body built by [`publisher_response_into_streaming_response`], +/// so the two async hold paths cannot drift apart. +async fn body_close_hold_loop_stream( + body: EdgeBody, + writer: &mut W, + processor: &mut P, + compression: Compression, + ctx: AuctionCollectCtx<'_>, + max_raw_body_bytes: usize, +) -> Result<(), Report> { + let AuctionCollectCtx { + dispatched, + telemetry, + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + } = ctx; + let mut decoder = BodyStreamDecoder::new(compression); + let mut encoder = BodyStreamEncoder::new(compression); + let mut source = + BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); + let mut state = AuctionHoldState::new(dispatched, telemetry); + let collect_refs = AuctionHoldCollectRefs { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + }; + + loop { + let raw_chunk = match source.next_chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(err) => { + abandon_hold_auction(&mut state, services, "stream_read_error").await; + return Err(err); + } + }; + let decoded = match decoder.decode_chunk(&raw_chunk) { + Ok(decoded) => decoded, + Err(err) => { + abandon_hold_auction(&mut state, services, "stream_decode_error").await; + return Err(err); + } + }; + if decoded.is_empty() { + continue; + } + for encoded in + hold_step_decoded_chunk(processor, &mut encoder, &decoded, &mut state, &collect_refs) + .await? + { + write_encoded_segment(writer, &encoded)?; + } + } + + for encoded in hold_finish_segments( + processor, + &mut decoder, + &mut encoder, + &mut state, + &collect_refs, + ) + .await? + { + write_encoded_segment(writer, &encoded)?; + } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + Ok(()) +} + const BODY_CLOSE_PREFIX: &[u8] = b"` to inject into, so bids are written to state up front and the +/// auction telemetry completes immediately. +async fn collect_non_html_auction( + dispatched: DispatchedAuction, + telemetry: AuctionTelemetryCarry, + params: &OwnedProcessResponseParams, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, + settings: &Settings, +) { + let placeholder = mediator_placeholder_request(); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), + ) + .await; + if let (Some(observation), Some(auction_request)) = + (telemetry.observation, telemetry.auction_request.as_ref()) + { + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ) + }) + .await; + } + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, + ); +} + async fn collect_stream_auction( dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry, @@ -1600,11 +2439,12 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - let mut response = match services - .http_client() - .send(PlatformHttpRequest::new(req, backend_name)) - .await - { + let mut platform_request = PlatformHttpRequest::new(req, backend_name); + if services.http_client().supports_streaming_responses() { + platform_request = platform_request.with_stream_response(); + } + + let mut response = match services.http_client().send(platform_request).await { Ok(platform_response) => platform_response.response, Err(err) => { if let Some(dispatched) = dispatched_auction.take() { @@ -2505,6 +3345,24 @@ mod tests { output } + fn deflate_encode(input: &[u8]) -> Vec { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(input) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + } + + fn deflate_decode(input: &[u8]) -> Vec { + let mut decoder = flate2::read::ZlibDecoder::new(input); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode deflate test output"); + output + } + fn brotli_encode(input: &[u8]) -> Vec { let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); encoder @@ -2734,50 +3592,107 @@ mod tests { } #[tokio::test] - async fn handle_publisher_request_does_not_self_generate_ec() { - // EC generation is the adapter's real-browser-gated responsibility. This - // handler must never mint an EC ID on its own: for a navigation from a - // client the adapter did not pre-generate for (e.g. a non-real browser), - // `ec_value` must stay `None` so no IP-derived identifier reaches the - // auction. Consent allows EC creation and a client IP is present here — - // exactly the conditions under which the old inline call would have - // generated one. + async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"ok".to_vec()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - - let consent = crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }; - let mut ec_context = - EcContext::new_for_test_with_ip(None, consent, Some("203.0.113.7".to_string())); - assert!( - ec_context.ec_allowed(), - "test precondition: consent must allow EC creation" - ); - - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let req = HttpRequest::builder() .method(Method::GET) - .uri("https://publisher.example/article") + .uri("https://publisher.example/page") .header(header::HOST, "publisher.example") - .header("sec-fetch-dest", "document") .body(EdgeBody::empty()) .expect("should build request"); - let _ = handle_publisher_request( - &settings, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &[], - registry: None, + let _ = run_publisher_proxy(&settings, &services, req).await; + + assert_eq!( + stub.recorded_stream_response_flags(), + vec![false], + "publisher origin fetch must not request streams when the platform does not support them" + ); + } + + #[tokio::test] + async fn publisher_origin_fetch_sets_stream_response_when_supported() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.set_streaming_responses_supported(true); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let _ = run_publisher_proxy(&settings, &services, req).await; + + assert_eq!( + stub.recorded_stream_response_flags(), + vec![true], + "publisher origin fetch should request streams when the platform supports them" + ); + } + + #[tokio::test] + async fn handle_publisher_request_does_not_self_generate_ec() { + // EC generation is the adapter's real-browser-gated responsibility. This + // handler must never mint an EC ID on its own: for a navigation from a + // client the adapter did not pre-generate for (e.g. a non-real browser), + // `ec_value` must stay `None` so no IP-derived identifier reaches the + // auction. Consent allows EC creation and a client IP is present here — + // exactly the conditions under which the old inline call would have + // generated one. + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = + EcContext::new_for_test_with_ip(None, consent, Some("203.0.113.7".to_string())); + assert!( + ec_context.ec_allowed(), + "test precondition: consent must allow EC creation" + ); + + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build request"); + + let _ = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, }, req, ) @@ -3659,6 +4574,734 @@ mod tests { ); } + #[test] + fn stream_publisher_body_rejects_stream_body_in_sync_path() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( + bytes::Bytes::from_static(b"live"), + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) + .expect_err("should reject stream body in sync path"); + + assert!( + format!("{err:?}").contains("streaming body"), + "should explain that Body::Stream is not supported by the sync path: {err:?}" + ); + } + + #[test] + fn body_chunk_source_yields_once_body_in_chunks() { + futures::executor::block_on(async { + let body = EdgeBody::from_bytes(bytes::Bytes::from_static(b"abcdef")); + let mut source = BodyChunkSource::new(body, 3).with_max_bytes(16); + + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"abc"[..]), + "should yield the first chunk" + ); + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"def"[..]), + "should yield the second chunk" + ); + assert!( + source.next_chunk().await.expect("should read").is_none(), + "should end after buffered bytes are exhausted" + ); + }); + } + + #[test] + fn body_chunk_source_preserves_stream_chunks() { + futures::executor::block_on(async { + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"first"), + bytes::Bytes::from_static(b"second"), + ])); + let mut source = BodyChunkSource::new(body, 3).with_max_bytes(16); + + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"first"[..]), + "stream chunks should pass through without re-chunking" + ); + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"second"[..]), + "stream chunks should preserve upstream boundaries" + ); + assert!( + source.next_chunk().await.expect("should read").is_none(), + "should end after stream is exhausted" + ); + }); + } + + #[test] + fn body_chunk_source_enforces_cumulative_raw_cap() { + futures::executor::block_on(async { + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"1234"), + bytes::Bytes::from_static(b"5678"), + ])); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(6); + + assert!( + source + .next_chunk() + .await + .expect("first chunk should pass") + .is_some(), + "first chunk should stay under cap" + ); + let err = source + .next_chunk() + .await + .expect_err("second chunk should exceed cap"); + + assert!( + format!("{err:?}").contains("publisher origin body exceeded"), + "should report cumulative cap: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), + bytes::Bytes::from_static(b"asset.png')}"), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("stream body should process on async path"); + + let css = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_gzip_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "gzip".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("gzip stream body should process on async path"); + + let css = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming gzip. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after gzip rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_deflate_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "deflate".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("deflate stream body should process on async path"); + + let css = String::from_utf8(deflate_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming deflate. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after deflate rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_brotli_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "br".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("brotli stream body should process on async path"); + + let css = String::from_utf8(brotli_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming brotli. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after brotli rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_rejects_truncated_brotli_stream() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "br".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let truncated = &compressed[..compressed.len() - 3]; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::copy_from_slice( + truncated, + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("truncated brotli stream must fail instead of truncating silently"); + + assert!( + format!("{err:?}").contains("brotli"), + "should surface the brotli finalization failure: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_stream_with_auction_hold() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let state = Arc::new(Mutex::new(None)); + let mut params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: Some( + r#""# + .to_string(), + ), + ad_bids_state: state, + auction_observation: None, + auction_request: Some(test_auction_request()), + dispatched_auction: Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )), + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello"), + bytes::Bytes::from_static(b""), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("stream body with auction should process on async path"); + + let html = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + html.contains("hello"), + "should preserve streamed HTML content. Got: {html}" + ); + assert!( + html.contains(".adSlots=JSON.parse"), + "should still inject ad slots. Got: {html}" + ); + assert!( + html.contains(".bids=JSON.parse"), + "should collect auction and inject bids before body close. Got: {html}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_non_html_stream_after_auction_collect() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: Some(test_auction_request()), + dispatched_auction: Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )), + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"body{background:url('https://origin.example.com/asset.png')}", + )])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("non-html stream body should process after auction collection"); + + let css = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite non-html stream after auction collection. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after rewrite. Got: {css}" + ); + }); + } + + fn drain_streaming_finalize_body(content_encoding: &str, body: EdgeBody) -> Vec { + let settings = Arc::new(create_test_settings()); + let registry = Arc::new( + IntegrationRegistry::new(&settings).expect("should create integration registry"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let services = noop_services(); + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/css") + .body(EdgeBody::empty()) + .expect("should build response"); + let params = OwnedProcessResponseParams { + content_encoding: content_encoding.to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let publisher_response = PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }; + + let response = publisher_response_into_streaming_response( + publisher_response, + &Method::GET, + Arc::clone(&settings), + registry.as_ref(), + orchestrator, + services, + ) + .expect("should build streaming response"); + + assert!( + matches!(response.body(), EdgeBody::Stream(_)), + "streaming finalize should keep a lazy Body::Stream" + ); + + futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("streaming body should drain") + .to_vec() + } + + #[test] + fn publisher_response_streaming_finalize_keeps_stream_body_lazy() { + let body_bytes = drain_streaming_finalize_body( + "", + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"body{background:url('https://origin.example.com/asset.png')}", + )])), + ); + let css = String::from_utf8(body_bytes).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response body should still run publisher rewriting. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response body should not leave origin URLs unrewritten. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_gzip_stream() { + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "gzip", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response finalize should rewrite gzip body. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response finalize should not leave gzip origin URLs. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_deflate_stream() { + let compressed = + deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "deflate", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(deflate_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response finalize should rewrite deflate body. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response finalize should not leave deflate origin URLs. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_brotli_stream() { + let compressed = + brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "br", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(brotli_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response finalize should rewrite brotli body. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response finalize should not leave brotli origin URLs. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_holds_auction_and_keeps_gzip_tail() { + let settings = Arc::new(create_test_settings()); + let registry = Arc::new( + IntegrationRegistry::new(&settings).expect("should create integration registry"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let services = noop_services(); + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build response"); + // The trailing content after `` must exceed the flate2 write + // decoder's 32 KiB internal output buffer: the close-body tag then + // surfaces (and releases the auction hold) mid-stream, while the + // trailing markup only surfaces at decoder finalization. This guards + // against the EOF decoded tail being dropped once the hold is gone. + let trailing_comment = format!("", "trailing-content ".repeat(3 * 1024)); + let page = format!("hello{trailing_comment}"); + let compressed = gzip_encode(page.as_bytes()); + let chunks: Vec = compressed + .chunks(STREAM_CHUNK_SIZE) + .map(bytes::Bytes::copy_from_slice) + .collect(); + let params = OwnedProcessResponseParams { + content_encoding: "gzip".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: Some( + r#""# + .to_string(), + ), + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: Some(test_auction_request()), + dispatched_auction: Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )), + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let publisher_response = PublisherResponse::Stream { + response, + body: EdgeBody::stream(futures::stream::iter(chunks)), + params: Box::new(params), + }; + + let response = publisher_response_into_streaming_response( + publisher_response, + &Method::GET, + Arc::clone(&settings), + registry.as_ref(), + orchestrator, + services, + ) + .expect("should build streaming response"); + + let output = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("streaming body should drain") + .to_vec(); + + let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + assert!( + html.contains(".bids=JSON.parse"), + "should collect the held auction and inject bids. Got tail: {}", + &html[html.len().saturating_sub(200)..] + ); + assert!( + html.contains("trailing-content"), + "should preserve content after the close-body tag" + ); + assert!( + html.trim_end().ends_with(""), + "should not drop the decoded tail once the auction hold is released. Got tail: {}", + &html[html.len().saturating_sub(200)..] + ); + } + #[test] fn stream_publisher_body_treats_mixed_case_html_as_html() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a546..008b4a2d5 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -50,15 +50,12 @@ pub struct Publisher { /// exceeding it fails the response rather than allocating past the cap. /// Defaults to 16 MiB — a conservative cap that prevents Wasm-heap OOM. /// - /// On Fastly the *effective* ceiling for a publisher page is lower: the - /// platform HTTP client rejects any origin response whose raw (still - /// compressed) body exceeds 10 MiB before this buffer is ever filled, so - /// raising this value only helps highly compressible pages whose decoded - /// size exceeds the 16 MiB default while their compressed origin body stays - /// under 10 MiB. Raising it above ~10 MiB does not lift the platform cap for - /// uncompressed pages. That platform limit is removed once true streaming - /// lands (tracked for PR 15, issue #495), after which this setting becomes - /// the sole ceiling. + /// Fastly origin bodies are preserved as streams on the publisher path, so + /// this setting is also the cumulative raw-byte cap while the streaming + /// processor decodes and rewrites chunks. Buffered adapters keep using it + /// as the post-rewrite output buffer cap. On the streaming path headers + /// are already committed when the cap trips, so the response is truncated + /// mid-body (with the error logged) rather than replaced with a 5xx. /// /// Must be at least 1: a zero-byte cap fails every non-empty buffered /// publisher response at request time, so it is rejected at config diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index 5692118a8..ef1a0bc55 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -349,6 +349,197 @@ impl StreamProcessor for StreamingReplacer { } } +/// Read buffer size for streaming body processing and brotli internal buffers. +/// Both the `Decompressor` and `CompressorWriter` use this value so all +/// brotli I/O layers operate on consistently-sized chunks. +pub(crate) const STREAM_CHUNK_SIZE: usize = 8192; + +/// Incremental push-style decompressor for the async chunk pipeline. +/// +/// Compressed bytes go in via [`Self::decode_chunk`]; decoded bytes drain +/// out of the internal buffer after every push. Write-based decoders are +/// used because the async publisher path cannot wrap a blocking `Read`. +pub(crate) enum BodyStreamDecoder { + None, + Gzip(flate2::write::GzDecoder>), + Deflate(flate2::write::ZlibDecoder>), + Brotli(Box>>), +} + +impl BodyStreamDecoder { + pub(crate) fn new(compression: Compression) -> Self { + match compression { + Compression::None => Self::None, + Compression::Gzip => Self::Gzip(flate2::write::GzDecoder::new(Vec::new())), + Compression::Deflate => Self::Deflate(flate2::write::ZlibDecoder::new(Vec::new())), + Compression::Brotli => Self::Brotli(Box::new(brotli::DecompressorWriter::new( + Vec::new(), + STREAM_CHUNK_SIZE, + ))), + } + } + + pub(crate) fn decode_chunk( + &mut self, + chunk: &[u8], + ) -> Result, Report> { + match self { + Self::None => Ok(chunk.to_vec()), + Self::Gzip(decoder) => { + decoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode gzip publisher body chunk".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Deflate(decoder) => { + decoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode deflate publisher body chunk".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Brotli(decoder) => { + decoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode brotli publisher body chunk".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + } + } + + pub(crate) fn finish(&mut self) -> Result, Report> { + match self { + Self::None => Ok(Vec::new()), + Self::Gzip(decoder) => { + decoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip publisher body decoder".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Deflate(decoder) => { + decoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body decoder".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Brotli(decoder) => { + // `close()` (not `flush()`): flush accepts a truncated brotli + // stream silently, while close validates end-of-stream and + // errors on incomplete input, matching the gzip/deflate arms. + decoder.close().change_context(TrustedServerError::Proxy { + message: "Failed to finalize brotli publisher body decoder".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + } + } +} + +/// Incremental push-style compressor mirroring [`BodyStreamDecoder`]. +/// +/// Processed bytes go in via [`Self::encode_chunk`]; encoded bytes drain out +/// after every push, and [`Self::finish`] emits the stream trailer. +pub(crate) enum BodyStreamEncoder { + None, + Gzip(flate2::write::GzEncoder>), + Deflate(flate2::write::ZlibEncoder>), + Brotli(Box>>), +} + +fn new_brotli_vec_encoder() -> brotli::enc::writer::CompressorWriter> { + let params = brotli::enc::BrotliEncoderParams { + quality: 4, + lgwin: 22, + ..Default::default() + }; + brotli::enc::writer::CompressorWriter::with_params(Vec::new(), STREAM_CHUNK_SIZE, ¶ms) +} + +impl BodyStreamEncoder { + pub(crate) fn new(compression: Compression) -> Self { + match compression { + Compression::None => Self::None, + Compression::Gzip => Self::Gzip(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )), + Compression::Deflate => Self::Deflate(flate2::write::ZlibEncoder::new( + Vec::new(), + flate2::Compression::default(), + )), + Compression::Brotli => Self::Brotli(Box::new(new_brotli_vec_encoder())), + } + } + + pub(crate) fn encode_chunk( + &mut self, + chunk: &[u8], + ) -> Result, Report> { + match self { + Self::None => Ok(chunk.to_vec()), + Self::Gzip(encoder) => { + encoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to encode gzip publisher body chunk".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Deflate(encoder) => { + encoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to encode deflate publisher body chunk".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Brotli(encoder) => { + encoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to encode brotli publisher body chunk".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + } + } + + pub(crate) fn finish(&mut self) -> Result, Report> { + match self { + Self::None => Ok(Vec::new()), + Self::Gzip(encoder) => { + encoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip publisher body encoder".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Deflate(encoder) => { + encoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body encoder".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Brotli(encoder) => { + let encoder = std::mem::replace(encoder, Box::new(new_brotli_vec_encoder())); + Ok((*encoder).into_inner()) + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md b/docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md new file mode 100644 index 000000000..bc1983a97 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md @@ -0,0 +1,1039 @@ +# True Origin Streaming Fastly Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix issue #849 for the production Fastly path so publisher HTML origin bodies stream through the rewrite pipeline to the client, with auction collection outside TTFB except for the held `` tail. + +**Architecture:** Keep one cohesive Fastly PR because the core pipeline, Fastly origin fetch, and Fastly finalize path are incomplete in isolation. Convert publisher body processing from sync `Read` over buffered bodies to async chunk-pull over `edgezero_core::body::Body`, then enable Fastly `with_stream_response()` and return a lazy streaming body for publisher responses. Leave Cloudflare, Spin, and Axum streaming as follow-up work. + +**Tech Stack:** Rust 2024, `edgezero_core::body::Body`, `futures::StreamExt`, `error-stack`, `flate2`, `brotli`, Fastly Compute, Viceroy tests. + +--- + +## Scope + +In scope: + +- Publisher HTML and processable publisher responses on the Fastly adapter. +- Core publisher pipeline support for `Body::Stream`. +- Fastly platform capability signaling for streaming origin responses. +- Fastly EdgeZero response delivery that streams publisher bodies to clients. +- Tests proving stream-vs-buffer parity, bodiless handling, stream caps, and Fastly routing behavior. + +Out of scope: + +- Cloudflare origin streaming. Current adapter rejects `PlatformHttpRequest::stream_response`. +- Spin streaming. Current adapter and upstream EdgeZero Spin conversion are buffered/blocking issues. +- Axum client streaming. Axum is dev-only and has `LocalBoxStream`/`Send` constraints. +- Parser-context `` scan fix from issue #850. +- Origin template caching and transformed HTML caching from issue #852. + +## Current Failure Points + +- `crates/trusted-server-adapter-fastly/src/platform.rs`: `fastly_response_to_platform(..., stream_response: false)` uses `take_body_bytes()` and the 10 MiB platform cap for publisher origin responses. +- `crates/trusted-server-core/src/publisher.rs`: `body_as_reader()` calls `body.into_bytes().unwrap_or_default()`, so `Body::Stream` becomes an empty body. +- `crates/trusted-server-core/src/publisher.rs`: `stream_html_with_auction_hold()` and `body_close_hold_loop()` are sync-`Read` based. +- `crates/trusted-server-adapter-fastly/src/app.rs`: publisher route calls `buffer_publisher_response_async()`, buffering all processed output and awaiting auction before any client bytes are sent. +- `crates/trusted-server-adapter-fastly/src/main.rs`: `send_edgezero_response()` already streams `EdgeBody::Stream`, but currently only asset responses reach that arm. + +## File Structure + +- Modify `crates/trusted-server-core/src/platform/http.rs` + - Add `PlatformHttpClient::supports_streaming_responses()` with default `false`. +- Modify adapter platform implementations: + - `crates/trusted-server-adapter-fastly/src/platform.rs`: return `true` for `supports_streaming_responses()`. + - `crates/trusted-server-adapter-cloudflare/src/platform.rs`: inherit default `false`. + - `crates/trusted-server-adapter-spin/src/platform.rs`: inherit default `false`. + - `crates/trusted-server-adapter-axum/src/platform.rs`: inherit default `false`. + - `crates/trusted-server-core/src/platform/test_support.rs`: configurable test support if needed. +- Modify `crates/trusted-server-core/src/streaming_processor.rs` + - Add small push decoder/encoder helpers only if keeping them here reduces duplication. + - Keep the existing `StreamingPipeline::process(Read, Write)` API for existing call sites. +- Modify `crates/trusted-server-core/src/publisher.rs` + - Replace publisher async processing internals with async chunk-pull. + - Keep public `buffer_publisher_response_async()` for buffered adapters. + - Add a streaming response constructor/helper for Fastly to use. + - Make `body_as_reader()` reject `Body::Stream` loudly or remove its use from any stream-capable path. +- Modify `crates/trusted-server-adapter-fastly/src/app.rs` + - Replace publisher `buffer_publisher_response_async()` call with streaming finalize for streamable publisher responses. + - Preserve buffered behavior for `PublisherResponse::Buffered`, pass-through/bodiless responses, and error paths. +- Modify `crates/trusted-server-adapter-fastly/src/main.rs` + - Reuse existing `EdgeBody::Stream` delivery. + - If publisher streaming needs a different log message from asset streaming, split the helper name/log text without changing behavior. +- Tests: + - `crates/trusted-server-core/src/publisher.rs` unit tests. + - `crates/trusted-server-core/src/streaming_processor.rs` unit tests if push codec helpers are introduced there. + - `crates/trusted-server-core/src/platform/test_support.rs` tests for capability behavior. + - `crates/trusted-server-adapter-fastly/src/app.rs` route tests for publisher streaming response shape. + +## Design Decisions + +- Use one PR for the full Fastly production fix. Intermediate merged PRs would create incomplete behavior and review confusion. +- Use existing `publisher.max_buffered_body_bytes` as the publisher body ceiling after streaming. `settings.rs` already documents that this becomes the sole ceiling after true streaming removes the 10 MiB Fastly materialization cap. +- Keep `Content-Length` removed for rewritten stream responses. Streaming output can change size due to URL rewriting and bid injection. +- Preserve bodiless behavior for `HEAD`, `204`, and `304`: do not attach or drive a body stream, and log abandoned/wasted auctions as current code does. +- Do not build a sync `Read` bridge over `Body::Stream`; nested `block_on` can panic on Fastly because the router already runs under `futures::executor::block_on`. +- Avoid adding `async-stream` initially. Use `futures::stream::unfold` or a custom stream type so the PR does not add a dependency unless the implementation becomes materially clearer. + +## Task 1: Baseline Tests for Stream Input Safety + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add a failing test for `Body::Stream` not becoming empty** + +Add a test near the existing `stream_publisher_body` tests: + +```rust +#[test] +fn stream_publisher_body_rejects_stream_body_in_sync_path() { + let settings = create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok(Bytes::from_static( + b"live", + ))])); + let params = test_process_params("text/html", ""); + let mut output = Vec::new(); + + let err = stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) + .expect_err("should reject stream body in sync path"); + + assert!( + format!("{err:?}").contains("streaming body"), + "should explain that Body::Stream is not supported by the sync path: {err:?}" + ); +} +``` + +- [ ] **Step 2: Run the targeted test and verify it fails** + +Run: + +```bash +cargo test-axum stream_publisher_body_rejects_stream_body_in_sync_path +``` + +Expected: FAIL because current `body_as_reader()` silently returns empty bytes. + +- [ ] **Step 3: Replace `body_as_reader()` with a fallible helper** + +Change `body_as_reader(body: EdgeBody) -> Cursor` to return `Result, Report>` and return a proxy error for `Body::Stream`. + +Minimal shape: + +```rust +fn body_as_reader(body: EdgeBody) -> Result, Report> { + let bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "streaming body cannot be processed by sync publisher pipeline".to_owned(), + }) + })?; + Ok(std::io::Cursor::new(bytes)) +} +``` + +Update existing sync call sites to use `body_as_reader(body)?`. + +- [ ] **Step 4: Run the targeted test and existing publisher sync tests** + +Run: + +```bash +cargo test-axum stream_publisher_body +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Reject publisher stream bodies on sync path" +``` + +## Task 2: Async Chunk Source and Cumulative Cap + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add tests for async chunk pulling** + +Add focused tests for a private helper that will pull chunks from both body variants: + +```rust +#[test] +fn body_chunk_source_yields_once_body_in_chunks() { + futures::executor::block_on(async { + let body = EdgeBody::from(Bytes::from_static(b"abcdef")); + let mut source = BodyChunkSource::new(body, 3); + + assert_eq!(source.next_chunk().await.expect("should read").as_deref(), Some(&b"abc"[..])); + assert_eq!(source.next_chunk().await.expect("should read").as_deref(), Some(&b"def"[..])); + assert!(source.next_chunk().await.expect("should read").is_none()); + }); +} +``` + +Add a separate test for `Body::Stream` preserving chunk boundaries and surfacing stream errors. + +- [ ] **Step 2: Add a failing test for the cumulative cap** + +Use a stream with two chunks whose total exceeds a small cap: + +```rust +#[test] +fn body_chunk_source_enforces_cumulative_raw_cap() { + futures::executor::block_on(async { + let body = EdgeBody::from_stream(futures::stream::iter(vec![ + Ok(Bytes::from_static(b"1234")), + Ok(Bytes::from_static(b"5678")), + ])); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(6); + + assert!(source.next_chunk().await.expect("first chunk should pass").is_some()); + let err = source.next_chunk().await.expect_err("second chunk should exceed cap"); + assert!( + format!("{err:?}").contains("publisher origin body exceeded"), + "should report cumulative cap: {err:?}" + ); + }); +} +``` + +- [ ] **Step 3: Run the new tests and verify they fail** + +Run: + +```bash +cargo test-axum body_chunk_source +``` + +Expected: FAIL because helper does not exist. + +- [ ] **Step 4: Implement `BodyChunkSource`** + +Implement a private helper near `STREAM_CHUNK_SIZE`: + +- Owns `EdgeBody`. +- For `Body::Once`, yields `Bytes` slices up to `chunk_size` without copying more than necessary. +- For `Body::Stream`, awaits `stream.next()`. +- Tracks cumulative raw bytes and errors when total exceeds `max_bytes`. +- Maps stream errors to `TrustedServerError::Proxy`. + +Do not use `block_on` inside the helper. + +- [ ] **Step 5: Run helper tests** + +Run: + +```bash +cargo test-axum body_chunk_source +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Add async publisher body chunk source" +``` + +## Task 3: Push Compression Helpers + +**Files:** + +- Modify: `crates/trusted-server-core/src/streaming_processor.rs` +- Or modify: `crates/trusted-server-core/src/publisher.rs` if helpers are publisher-only + +- [ ] **Step 1: Add parity tests for compressed chunk processing** + +For each compression mode used by publisher HTML (`gzip`, `deflate`, `br`), add a test that feeds compressed HTML in multiple raw chunks through the future async path and verifies the decompressed/processed/recompressed output decodes to expected HTML. + +Start with gzip: + +```rust +#[test] +fn async_publisher_pipeline_preserves_gzip_html_across_stream_chunks() { + futures::executor::block_on(async { + let compressed = gzip_bytes(b"Hello"); + let body = EdgeBody::from_stream(bytes_to_two_chunk_stream(compressed)); + let output = process_test_body_async(body, "text/html", "gzip") + .await + .expect("should process gzip stream"); + + assert_eq!( + gunzip_bytes(&output), + b"Hello" + ); + }); +} +``` + +Use existing HTML processor expectations rather than inventing new behavior. + +- [ ] **Step 2: Run the gzip test and verify it fails** + +Run: + +```bash +cargo test-axum async_publisher_pipeline_preserves_gzip_html_across_stream_chunks +``` + +Expected: FAIL because async compressed processing does not exist. + +- [ ] **Step 3: Implement write-based push decoders** + +Use write-based APIs: + +- `flate2::write::GzDecoder` +- `flate2::write::ZlibDecoder` +- `brotli::DecompressorWriter` + +The helper should: + +- Accept raw compressed chunks. +- Write decoded bytes into an internal `Vec` sink. +- Return newly decoded bytes after each input chunk. +- Finalize at EOF and return any decoder tail bytes. +- Surface decoder errors as `TrustedServerError::Proxy`. + +Keep this helper private unless tests or other modules need it. + +- [ ] **Step 4: Implement output encoding wrapper** + +Continue to use existing write-based encoders: + +- `flate2::write::GzEncoder` +- `flate2::write::ZlibEncoder` +- `brotli::enc::writer::CompressorWriter` + +The async loop should write processed decoded chunks into the encoder and finalize once. + +- [ ] **Step 5: Add deflate and brotli tests** + +Run: + +```bash +cargo test-axum async_publisher_pipeline_preserves_ +``` + +Expected: gzip, deflate, and brotli async parity tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/streaming_processor.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Add push compression support for publisher streams" +``` + +## Task 4: Async Publisher Pipeline Without Auction + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add stream-vs-once parity tests for no-auction paths** + +Cover: + +- HTML rewrite. +- RSC flight rewrite. +- Generic URL replacement. +- Unsupported stream body cannot reach sync path. + +Example: + +```rust +#[test] +fn stream_publisher_body_async_matches_buffered_html_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let html = Bytes::from_static(b"x"); + + let mut once_params = test_process_params("text/html", ""); + let mut once_output = Vec::new(); + stream_publisher_body_async( + EdgeBody::from(html.clone()), + &mut once_output, + &mut once_params, + &settings, + ®istry, + &AuctionOrchestrator::new(settings.auction.clone()), + &noop_services(), + ) + .await + .expect("once body should process"); + + let mut stream_params = test_process_params("text/html", ""); + let mut stream_output = Vec::new(); + stream_publisher_body_async( + EdgeBody::from_stream(futures::stream::iter(vec![Ok(html)])), + &mut stream_output, + &mut stream_params, + &settings, + ®istry, + &AuctionOrchestrator::new(settings.auction.clone()), + &noop_services(), + ) + .await + .expect("stream body should process"); + + assert_eq!(stream_output, once_output); + }); +} +``` + +- [ ] **Step 2: Run parity tests and verify they fail** + +Run: + +```bash +cargo test-axum stream_publisher_body_async_matches_buffered +``` + +Expected: FAIL because no-auction async path still delegates to sync processing. + +- [ ] **Step 3: Refactor `process_response_streaming` into reusable processor construction** + +Extract the shared routing logic into a helper such as: + +```rust +enum PublisherProcessor { + Html(HtmlRewriterAdapter), + Rsc(RscFlightUrlRewriter), + Url(StreamingReplacer), +} +``` + +Or use a generic closure/helper if that fits existing patterns better. The goal is to avoid duplicating content-type routing between sync and async paths. + +- [ ] **Step 4: Drive all `stream_publisher_body_async()` calls through async chunk-pull** + +Even when `params.dispatched_auction` is `None`, build the same processor and use `BodyChunkSource`. This prevents stream bodies from falling into the sync path. + +- [ ] **Step 5: Keep `stream_publisher_body()` for compatibility** + +The sync function should remain for old tests and any current non-stream callers, but it must not be used by the async path once this task is complete. + +- [ ] **Step 6: Run targeted tests** + +Run: + +```bash +cargo test-axum stream_publisher_body_async_matches_buffered +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Drive publisher async processing from body chunks" +``` + +## Task 5: Async Auction Hold Loop + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Replace reader-based hold-loop test with stream-based test** + +Update or add a test based on `body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks()`: + +- Feed pre-`` chunk. +- Feed held `` chunk. +- Feed post-body chunk. +- Assert the loop collects auction immediately when `` test** + +Verify that auction collection happens at EOF and finalization still calls `processor.process_chunk(&[], true)`. + +- [ ] **Step 3: Add stream error abandonment test** + +Feed a stream error after dispatch and assert telemetry abandonment uses `stream_read_error` or the current expected reason. + +- [ ] **Step 4: Run tests and verify failures** + +Run: + +```bash +cargo test-axum body_close_hold_loop +``` + +Expected: FAIL until the loop consumes `BodyChunkSource`. + +- [ ] **Step 5: Change `body_close_hold_loop` to async chunk-pull** + +Replace: + +```rust +async fn body_close_hold_loop(...) +``` + +with a shape that accepts decoded chunks from an async driver, or accepts `BodyChunkSource` plus codec state. Keep the control flow: + +- Push decoded chunks into `BodyCloseHoldBuffer`. +- Write ready bytes immediately. +- On first ` bool { + false +} +``` + +In `FastlyPlatformHttpClient`: + +```rust +fn supports_streaming_responses(&self) -> bool { + true +} +``` + +In `StubHttpClient`, add a configurable flag if tests need both states. + +- [ ] **Step 4: Enable publisher origin streaming behind the gate** + +At the publisher origin fetch: + +```rust +let mut platform_request = PlatformHttpRequest::new(req, backend_name); +if services.http_client().supports_streaming_responses() { + platform_request = platform_request.with_stream_response(); +} +let mut response = services.http_client().send(platform_request).await?; +``` + +- [ ] **Step 5: Run capability tests** + +Run: + +```bash +cargo test-axum publisher_origin_fetch_sets_stream_response_when_supported +cargo test-axum publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-adapter-fastly/src/platform.rs crates/trusted-server-core/src/platform/test_support.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Gate publisher origin streaming by platform capability" +``` + +## Task 8: Fastly Publisher Streaming Finalize + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` if a helper is needed +- Possibly modify: `crates/trusted-server-adapter-fastly/src/main.rs` for logging/helper naming + +- [ ] **Step 1: Add Fastly route test for publisher response body shape** + +In Fastly app tests, configure a publisher HTML origin response and assert the router returns `Body::Stream` for processable publisher responses on `GET`. + +Expected assertion: + +```rust +assert!( + matches!(response.body(), Body::Stream(_)), + "processable publisher response should remain streaming on Fastly" +); +``` + +- [ ] **Step 2: Add bodiless route tests** + +Assert `HEAD`, `204`, and `304` publisher responses do not carry a stream body, preserving existing metadata. + +- [ ] **Step 3: Run tests and verify failure** + +Run: + +```bash +cargo test-fastly publisher_response_streams +``` + +Expected: FAIL because app still calls `buffer_publisher_response_async()`. + +- [ ] **Step 4: Add a core helper to convert `PublisherResponse` to streaming response** + +Preferred shape in `publisher.rs`: + +```rust +pub fn publisher_response_into_streaming_body( + publisher_response: PublisherResponse, + method: &Method, + settings: Arc, + integration_registry: Arc, + orchestrator: Arc, + services: RuntimeServices, +) -> Result, Report> +``` + +The helper should: + +- Return `PublisherResponse::Buffered` unchanged. +- Return `PublisherResponse::PassThrough` with body attached, except bodiless responses. +- For `PublisherResponse::Stream`, build `EdgeBody::from_stream(futures::stream::unfold(...))` or equivalent. +- Move `OwnedProcessResponseParams`, origin body, settings, registry, orchestrator, and services into the stream state. +- Yield processed chunks as they become available. +- On mid-stream processing error, log and end the stream. The client sees a truncated body, matching existing mid-stream error behavior. + +If borrowing/lifetime pressure is high, keep the helper in Fastly `app.rs` and call core `stream_publisher_body_async()` from inside the stream. Prefer core if it avoids Fastly-specific body processing logic. + +- [ ] **Step 5: Replace Fastly buffered finalize for publisher route** + +In `handle_publisher_route`, replace the `buffer_publisher_response_async()` call for Fastly with the streaming helper. Keep non-Fastly adapters on `buffer_publisher_response_async()`. + +- [ ] **Step 6: Preserve entry-point finalization** + +Verify the returned `Response` still carries extensions needed by `main.rs`: + +- `EcFinalizeState` +- `RequestFilterEffects` +- Final cache privacy guard + +Headers must be finalized before `send_edgezero_response()` splits the response and commits headers. + +- [ ] **Step 7: Run Fastly route tests** + +Run: + +```bash +cargo test-fastly publisher_response +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Stream Fastly publisher responses to clients" +``` + +## Task 9: Pass-Through Publisher Bodies + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` + +- [ ] **Step 1: Add pass-through large body test** + +Verify a non-processable successful publisher response (`image/png`, font, video) is returned as `Body::Stream` when origin streaming is supported and body is not bodiless. + +- [ ] **Step 2: Add pass-through bodiless test** + +Verify `HEAD`, `204`, and `304` pass-through arms preserve headers but do not attach/drain the stream body. + +- [ ] **Step 3: Run targeted tests** + +Run: + +```bash +cargo test-fastly publisher_pass_through +``` + +Expected: FAIL if pass-through still buffers or attaches a body for bodiless responses. + +- [ ] **Step 4: Make pass-through use the same body-carrying guard** + +Mirror `asset_response_carries_body()` semantics in publisher finalize. If `response_carries_body(method, status)` is false, drop the body and return headers only. + +- [ ] **Step 5: Run targeted tests** + +Run: + +```bash +cargo test-fastly publisher_pass_through +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Preserve publisher pass-through streaming semantics" +``` + +## Task 10: Headers, Length, and Error Semantics + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` if logs are misleading + +- [ ] **Step 1: Add header tests** + +Assert for streamed processed publisher responses: + +- `Content-Length` is absent. +- `Transfer-Encoding` is not manually set. +- `Content-Encoding` is preserved when recompression is used. +- Cache/privacy headers still downgrade when `Set-Cookie` is present. + +- [ ] **Step 2: Add mid-stream cap/error test** + +Use a body stream that exceeds `publisher.max_buffered_body_bytes` after headers would be committed. Assert the stream returns an error/truncates consistently with existing mid-stream asset behavior and logs enough context. + +- [ ] **Step 3: Run tests and verify failures** + +Run: + +```bash +cargo test-fastly publisher_stream +``` + +Expected: FAIL until header/error cleanup is complete. + +- [ ] **Step 4: Clean header handling** + +Ensure the existing `response.headers_mut().remove(header::CONTENT_LENGTH)` remains on `PublisherResponse::Stream`. Do not re-add content length for streaming finalize. + +- [ ] **Step 5: Improve log wording** + +If `main.rs` still logs "asset streaming" for all `EdgeBody::Stream` responses, rename log messages to "EdgeZero streaming body" or split publisher/asset helpers. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +cargo test-fastly publisher_stream +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Tighten publisher streaming headers and errors" +``` + +## Task 11: End-to-End Regression Coverage + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: existing integration/parity tests if appropriate + +- [ ] **Step 1: Add a slow-origin behavior test if feasible in existing harness** + +Preferred test shape: + +- Origin response body is a stream with first chunk available immediately and second chunk delayed or instrumented. +- Router returns `Body::Stream` without collecting the whole body. +- Pulling the first output chunk does not require pulling the entire origin stream. + +If the harness cannot model time cleanly, use an instrumented stream that panics if polled past the first chunk before the returned response body is consumed. + +- [ ] **Step 2: Add auction timing test** + +Verify response construction does not await `collect_dispatched_auction`; collection happens when the body stream is pulled and reaches `` or EOF. + +- [ ] **Step 3: Run targeted tests** + +Run: + +```bash +cargo test-fastly publisher_streaming_does_not_buffer_origin_before_response +``` + +Expected: PASS after Fastly finalize is lazy. + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Cover lazy publisher streaming behavior" +``` + +## Task 12: Documentation Cleanup + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Optionally modify: issue/PR description only, not repo docs + +- [ ] **Step 1: Update stale interim comments** + +Remove or rewrite comments saying publisher stream bodies are already materialized into WASM heap. + +Targets: + +- `PublisherResponse::Stream` doc. +- `PublisherResponse::PassThrough` doc if Fastly now preserves stream bodies. +- `settings.rs` comments that reference future true streaming. +- Fastly app module comment that says publisher responses are buffered by `publisher.max_buffered_body_bytes`. + +- [ ] **Step 2: Run doc-related checks locally** + +Run: + +```bash +cargo fmt --all -- --check +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/settings.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Update publisher streaming documentation" +``` + +## Task 13: Full Verification + +**Files:** + +- No source edits unless failures reveal issues. + +- [ ] **Step 1: Run formatting** + +Run: + +```bash +cargo fmt --all -- --check +``` + +Expected: PASS. + +- [ ] **Step 2: Run target checks** + +Run: + +```bash +cargo check-fastly +cargo check-axum +cargo check-cloudflare +``` + +Expected: PASS. + +- [ ] **Step 3: Run target tests** + +Run: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +``` + +Expected: PASS. + +- [ ] **Step 4: Run Spin if touched by shared trait changes** + +Run: + +```bash +cargo test-spin +``` + +Expected: PASS. + +- [ ] **Step 5: Run clippy gates** + +Run: + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: PASS. + +- [ ] **Step 6: Run parity suite if available locally** + +Run: + +```bash +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: PASS. + +- [ ] **Step 7: Optional local TTFB smoke** + +Run Fastly local serve against an artificially slow publisher origin: + +- Origin sends headers and first HTML chunk immediately. +- Origin delays later body chunks. +- Verify browser/curl receives response headers and first chunk before full origin drain. +- Verify bids still inject before `` when auction completes. + +Expected: TTFB tracks origin first byte, not full origin transfer or auction collection. + +## Review Checklist + +- [ ] No `block_on` inside stream body processing or `Read::read` equivalents. +- [ ] `Body::Stream` never falls through `into_bytes().unwrap_or_default()`. +- [ ] Fastly publisher origin fetch sets `with_stream_response()` only through capability gate. +- [ ] Cloudflare, Spin, and Axum do not start receiving stream-response requests. +- [ ] `HEAD`, `204`, and `304` do not drive or attach response bodies. +- [ ] `Content-Length` is absent on processed streaming responses. +- [ ] Existing buffered adapters still work through `buffer_publisher_response_async()`. +- [ ] Auction telemetry handles completed and abandoned stream cases. +- [ ] Mid-stream errors do not panic; they log and truncate consistently with current streaming behavior. +- [ ] Comments no longer describe publisher streaming as interim/in-memory cursor based. + +## PR Description Skeleton + +```markdown +## Summary + +- convert publisher response processing to async chunk-pull over `Body::Stream` +- enable Fastly publisher origin streaming behind a platform capability gate +- stream Fastly publisher responses to clients instead of buffering and awaiting auction before send + +## Scope + +Fastly production path for issue #849. Cloudflare, Spin, and Axum streaming remain follow-up work. + +## Tests + +- [ ] cargo fmt --all -- --check +- [ ] cargo check-fastly +- [ ] cargo check-axum +- [ ] cargo check-cloudflare +- [ ] cargo test-fastly +- [ ] cargo test-axum +- [ ] cargo test-cloudflare +- [ ] cargo test-spin +- [ ] cargo clippy-fastly +- [ ] cargo clippy-axum +- [ ] cargo clippy-cloudflare +- [ ] cargo clippy-cloudflare-wasm +- [ ] cargo clippy-spin-native +- [ ] cargo clippy-spin-wasm +``` + +## Known Follow-Ups + +- Cloudflare origin streaming once Worker `ReadableStream` is wrapped into `Body::Stream` and response header/set-cookie behavior is verified. +- Spin streaming after upstream EdgeZero Spin response conversion supports incremental body writes. +- Axum streaming only if the dev server needs it enough to justify a `Send` bridge. +- Issue #850 parser-context `` detection. From affb46c9781239ae78695ad5157826ea232e143e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 22:39:26 +0530 Subject: [PATCH 003/494] Harden the streaming publisher pipeline after review Address the deep-review findings on the streaming cutover: - Cap cumulative decoded bytes in BodyStreamDecoder against publisher.max_buffered_body_bytes: the chunk source only bounds raw compressed bytes, so a decompression bomb could expand ~1000x past it and push unbounded decoded volume through the rewrite pipeline - Detect truncated deflate streams: write::ZlibDecoder::try_finish accepts truncated input silently, so the deflate arm now drives flate2::Decompress directly and requires Status::StreamEnd at finalization; trailing bytes after the end marker stay ignored. Add truncated-gzip and truncated-deflate regression tests - Make BodyChunkSource::next_chunk cancellation-safe by polling the body in place instead of moving it out across an await; a cancelled pull no longer turns into a silent EOF - Log dispatched auctions dropped uncollected (client disconnect mid-stream or never-polled body) via DispatchedAuctionGuard; the guard is created before the lazy stream so unpolled drops log too - Share the pull+decode step between the lazy publisher body and the write-sink drivers (hold_step_next_chunk / passthrough_step), removing the unreachable!() error plumbing and the triplicated processor selection; document body_close_hold_loop_stream as groundwork for the buffered adapters' streaming cutover - Pass identity-encoded chunks through zero-copy and finish encoders by consuming them instead of allocating a throwaway replacement - Add a Fastly dispatch test asserting the publisher fallback returns Body::Stream without a stale Content-Length, plus a comment on why the publisher fetch gates streaming on capability while the asset path does not Behavior note: gzip bodies with trailing garbage after the trailer now error mid-stream; the old read-path decoder ignored them. --- .../trusted-server-adapter-fastly/src/app.rs | 34 + crates/trusted-server-core/src/publisher.rs | 639 ++++++++++++------ crates/trusted-server-core/src/settings.rs | 13 +- .../src/streaming_processor.rs | 297 ++++++-- 4 files changed, 685 insertions(+), 298 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d5e37f91a..ae9a2749b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2150,6 +2150,10 @@ mod tests { #[async_trait::async_trait(?Send)] impl PlatformHttpClient for StreamingHttpClient { + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send( &self, request: PlatformHttpRequest, @@ -2260,6 +2264,36 @@ mod tests { ); } + #[test] + fn dispatch_fallback_streams_publisher_body_without_buffering() { + // Regression guard for the publisher streaming cutover (#849): a + // successful publisher origin fetch must hand `edgezero_main` a lazy + // streaming body (`Body::Stream`) so headers commit at origin first + // byte, rather than draining the processed page into a buffered + // `Body::Once`. Core tests cover the rewrite pipeline itself; this + // guards the adapter wiring that could silently re-buffer. + let settings = test_settings(); + let state = build_state_from_settings(settings).expect("should build state"); + let services = streaming_runtime_services(); + let req = empty_request(Method::GET, "/article"); + + let response = block_on(super::dispatch_fallback(&state, &services, req)); + + assert_eq!( + response.status(), + StatusCode::OK, + "publisher proxy should succeed against the streaming origin stub" + ); + assert!( + matches!(response.body(), Body::Stream(_)), + "EdgeZero publisher dispatch must attach the lazy streaming body, not buffer it" + ); + assert!( + !response.headers().contains_key(header::CONTENT_LENGTH), + "processed streaming publisher responses must not carry a stale Content-Length" + ); + } + #[test] fn dispatch_runs_request_filter_and_threads_response_effects() { // Regression guard for the EdgeZero request-filter bypass: the publisher diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c10ac0b39..fe6467e12 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -104,40 +104,40 @@ impl BodyChunkSource { } async fn next_chunk(&mut self) -> Result, Report> { - let Some(body) = self.body.take() else { - return Ok(None); - }; - - let chunk = match body { - EdgeBody::Once(bytes) => { - if self.once_offset >= bytes.len() { - None + // The body is polled in place (never moved out across an await) so a + // cancelled `next_chunk` future leaves the source resumable instead of + // silently reporting end-of-stream on the next call. + let pulled = match &mut self.body { + None => Ok(None), + Some(EdgeBody::Once(bytes)) => { + let end = (self.once_offset + self.chunk_size).min(bytes.len()); + if self.once_offset >= end { + Ok(None) } else { - let end = (self.once_offset + self.chunk_size).min(bytes.len()); let chunk = bytes.slice(self.once_offset..end); self.once_offset = end; - if self.once_offset < bytes.len() { - self.body = Some(EdgeBody::Once(bytes)); - } - Some(chunk) + Ok(Some(chunk)) } } - EdgeBody::Stream(mut stream) => match stream.next().await { - Some(Ok(chunk)) => { - self.body = Some(EdgeBody::Stream(stream)); - Some(chunk) - } - Some(Err(err)) => { - return Err(Report::new(TrustedServerError::Proxy { - message: format!("Failed to read publisher origin body stream: {err}"), - })); - } - None => None, + Some(EdgeBody::Stream(stream)) => match stream.next().await { + Some(Ok(chunk)) => Ok(Some(chunk)), + Some(Err(err)) => Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to read publisher origin body stream: {err}"), + })), + None => Ok(None), }, }; - let Some(chunk) = chunk else { - return Ok(None); + let chunk = match pulled { + Ok(Some(chunk)) => chunk, + Ok(None) => { + self.body = None; + return Ok(None); + } + Err(err) => { + self.body = None; + return Err(err); + } }; self.bytes_seen = self.bytes_seen.checked_add(chunk.len()).ok_or_else(|| { @@ -174,19 +174,17 @@ fn process_and_encode_chunk( if processed.is_empty() { return Ok(None); } - let encoded = encoder.encode_chunk(&processed)?; + let encoded = encoder.encode_chunk(processed)?; if encoded.is_empty() { return Ok(None); } Ok(Some(bytes::Bytes::from(encoded))) } +// By-value signature so `map_err(publisher_stream_error)` works directly. +#[allow(clippy::needless_pass_by_value)] fn publisher_stream_error(err: Report) -> std::io::Error { - let message = format!("{err:?}"); - // Consume the report so clippy's needless_pass_by_value accepts the - // by-value signature that `map_err(publisher_stream_error)` requires. - drop(err); - std::io::Error::other(message) + std::io::Error::other(format!("{err:?}")) } fn not_found_response() -> Response { @@ -473,65 +471,58 @@ fn process_response_streaming( async fn process_response_streaming_async( body: EdgeBody, output: &mut W, - params: &ProcessResponseParams<'_>, - max_raw_body_bytes: usize, + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { - let is_html = is_html_content_type(params.content_type); - let is_rsc_flight = - content_type_contains_ascii_case_insensitive(params.content_type, "text/x-component"); log::debug!( - "process_response_streaming_async: content_type={}, content_encoding={}, is_html={}, is_rsc_flight={}", + "process_response_streaming_async: content_type={}, content_encoding={}", params.content_type, - params.content_encoding, - is_html, - is_rsc_flight + params.content_encoding ); - let compression = Compression::from_content_encoding(params.content_encoding); + let compression = Compression::from_content_encoding(¶ms.content_encoding); + let mut processor = PublisherBodyProcessor::new(params, settings, integration_registry)?; + process_body_chunks_async( + body, + output, + &mut processor, + compression, + settings.publisher.max_buffered_body_bytes, + ) + .await +} - if is_html { - let mut 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(), - )?; - process_body_chunks_async( - body, - output, - &mut processor, - compression, - max_raw_body_bytes, - ) - .await - } else if is_rsc_flight { - let mut processor = RscFlightUrlRewriter::new( - params.origin_host, - params.origin_url, - params.request_host, - params.request_scheme, - ); - process_body_chunks_async( - body, - output, - &mut processor, - compression, - max_raw_body_bytes, - ) - .await - } else { - let mut replacer = create_url_replacer( - params.origin_host, - params.origin_url, - params.request_host, - params.request_scheme, - ); - process_body_chunks_async(body, output, &mut replacer, compression, max_raw_body_bytes) - .await +/// Pull, decode, process, and encode the next chunk of a no-hold pipeline. +/// +/// Returns `Ok(None)` when the source is exhausted; the caller must then emit +/// [`passthrough_finish_segments`]. Shared by the write-sink driver +/// ([`process_body_chunks_async`]) and the lazy publisher body stream so the +/// two no-hold paths cannot drift apart. +async fn passthrough_step( + source: &mut BodyChunkSource, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, + processor: &mut P, +) -> Result>, Report> { + let Some(raw_chunk) = source.next_chunk().await? else { + return Ok(None); + }; + let decoded = decoder.decode_chunk(raw_chunk)?; + if decoded.is_empty() { + return Ok(Some(Vec::new())); } + let mut segments = Vec::new(); + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &decoded, + false, + "Failed to process chunk", + )? { + segments.push(encoded); + } + Ok(Some(segments)) } async fn process_body_chunks_async( @@ -539,25 +530,16 @@ async fn process_body_chunks_async( writer: &mut W, processor: &mut P, compression: Compression, - max_raw_body_bytes: usize, + max_body_bytes: usize, ) -> Result<(), Report> { - let mut decoder = BodyStreamDecoder::new(compression); + let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); - let mut source = - BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); - while let Some(chunk) = source.next_chunk().await? { - let decoded = decoder.decode_chunk(&chunk)?; - if decoded.is_empty() { - continue; - } - if let Some(encoded) = process_and_encode_chunk( - processor, - &mut encoder, - &decoded, - false, - "Failed to process chunk", - )? { + while let Some(segments) = + passthrough_step(&mut source, &mut decoder, &mut encoder, processor).await? + { + for encoded in segments { write_encoded_segment(writer, &encoded)?; } } @@ -621,18 +603,51 @@ fn passthrough_finish_segments( Ok(segments) } +/// Owns a [`DispatchedAuction`] and logs if it is dropped uncollected. +/// +/// The lazy publisher body stream can be dropped at any await point — a +/// client disconnect aborts the transfer mid-body, or the response may never +/// be polled at all. Async telemetry cannot run in `Drop`, so the loss is +/// surfaced in logs; the abandoned-auction telemetry event is only emitted on +/// error paths that can still await (see [`abandon_hold_auction`]). +struct DispatchedAuctionGuard { + dispatched: Option, +} + +impl DispatchedAuctionGuard { + fn new(dispatched: DispatchedAuction) -> Self { + Self { + dispatched: Some(dispatched), + } + } + + fn take(&mut self) -> Option { + self.dispatched.take() + } +} + +impl Drop for DispatchedAuctionGuard { + fn drop(&mut self) { + if self.dispatched.is_some() { + log::warn!( + "Dispatched server-side auction dropped without collection; SSP bid responses discarded (publisher body stream aborted or never polled)" + ); + } + } +} + /// Mutable auction-hold state threaded through the streaming hold pipeline. struct AuctionHoldState { hold: Option, - dispatched: Option, + dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry, } impl AuctionHoldState { - fn new(dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry) -> Self { + fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self { Self { hold: Some(BodyCloseHoldBuffer::new()), - dispatched: Some(dispatched), + dispatched, telemetry, } } @@ -736,6 +751,45 @@ async fn hold_step_decoded_chunk( Ok(segments) } +/// Pull and decode the next chunk of the close-body hold pipeline, feeding it +/// through [`hold_step_decoded_chunk`]. +/// +/// Returns `Ok(None)` when the source is exhausted; the caller must then emit +/// [`hold_finish_segments`]. On read or decode failure the pending auction is +/// abandoned before the error is returned. Shared by the write-sink driver +/// ([`body_close_hold_loop_stream`]) and the lazy publisher body stream so +/// the two hold paths cannot drift apart. +async fn hold_step_next_chunk( + source: &mut BodyChunkSource, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, + processor: &mut P, + state: &mut AuctionHoldState, + collect_refs: &AuctionHoldCollectRefs<'_>, +) -> Result>, Report> { + let raw_chunk = match source.next_chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => return Ok(None), + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_read_error").await; + return Err(err); + } + }; + let decoded = match decoder.decode_chunk(raw_chunk) { + Ok(decoded) => decoded, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; + return Err(err); + } + }; + if decoded.is_empty() { + return Ok(Some(Vec::new())); + } + hold_step_decoded_chunk(processor, encoder, &decoded, state, collect_refs) + .await + .map(Some) +} + /// Finalize the close-body hold pipeline at end of the origin stream. /// /// Drains the decoder tail through the hold (or straight through when the @@ -843,7 +897,11 @@ fn create_html_stream_processor( /// Result of publisher request handling, indicating whether the response body /// should be streamed or has already been buffered. pub enum PublisherResponse { - /// Response is fully buffered and ready to send via `send_to_client()`. + /// Response returned unmodified, ready to send via `send_to_client()`. + /// + /// On streaming adapters the unmodified body may still be a live + /// [`EdgeBody::Stream`] (the origin fetch requested streaming before the + /// response was classified); it passes through to the client untouched. Buffered(Response), /// Response headers are ready for a streaming response. Covers processable /// content on any status (2xx or non-2xx — e.g., branded 404/500 HTML and @@ -1085,25 +1143,31 @@ pub fn publisher_response_into_streaming_response( let mut params = *params; let mut processor = PublisherBodyProcessor::new(¶ms, &settings, integration_registry)?; + // The guard is created before the lazy stream so an auction whose + // response body is dropped unpolled still logs the loss. + let dispatched_auction = params.dispatched_auction.take().map(|dispatched| { + let telemetry = AuctionTelemetryCarry { + observation: params.auction_observation.take(), + auction_request: params.auction_request.take(), + }; + (DispatchedAuctionGuard::new(dispatched), telemetry) + }); let stream = async_stream::try_stream! { let compression = Compression::from_content_encoding(¶ms.content_encoding); - let mut decoder = BodyStreamDecoder::new(compression); + let max_body_bytes = settings.publisher.max_buffered_body_bytes; + let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) - .with_max_bytes(settings.publisher.max_buffered_body_bytes); + .with_max_bytes(max_body_bytes); // HTML rides the close-body hold so bids land before ``; // non-HTML has no injection point, so its auction is collected // before any byte streams (matching the buffered finalizer). let mut hold_auction = None; - if let Some(dispatched) = params.dispatched_auction.take() { - let telemetry = AuctionTelemetryCarry { - observation: params.auction_observation.take(), - auction_request: params.auction_request.take(), - }; + if let Some((mut guard, telemetry)) = dispatched_auction { if is_html_content_type(¶ms.content_type) { - hold_auction = Some((dispatched, telemetry)); - } else { + hold_auction = Some((guard, telemetry)); + } else if let Some(dispatched) = guard.take() { collect_non_html_auction( dispatched, telemetry, @@ -1116,8 +1180,8 @@ pub fn publisher_response_into_streaming_response( } } - if let Some((dispatched, telemetry)) = hold_auction { - let mut state = AuctionHoldState::new(dispatched, telemetry); + if let Some((guard, telemetry)) = hold_auction { + let mut state = AuctionHoldState::new(guard, telemetry); let collect_refs = AuctionHoldCollectRefs { price_granularity: params.price_granularity, ad_bids_state: ¶ms.ad_bids_state, @@ -1126,39 +1190,18 @@ pub fn publisher_response_into_streaming_response( settings: &settings, }; - loop { - let raw_chunk = match source.next_chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => break, - Err(err) => { - abandon_hold_auction(&mut state, &services, "stream_read_error") - .await; - Err(publisher_stream_error(err))?; - unreachable!("error should have returned"); - } - }; - let decoded = match decoder.decode_chunk(&raw_chunk) { - Ok(decoded) => decoded, - Err(err) => { - abandon_hold_auction(&mut state, &services, "stream_decode_error") - .await; - Err(publisher_stream_error(err))?; - unreachable!("error should have returned"); - } - }; - if decoded.is_empty() { - continue; - } - for encoded in hold_step_decoded_chunk( - &mut processor, - &mut encoder, - &decoded, - &mut state, - &collect_refs, - ) - .await - .map_err(publisher_stream_error)? - { + while let Some(segments) = hold_step_next_chunk( + &mut source, + &mut decoder, + &mut encoder, + &mut processor, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + for encoded in segments { yield encoded; } } @@ -1176,24 +1219,16 @@ pub fn publisher_response_into_streaming_response( yield encoded; } } else { - while let Some(raw_chunk) = - source.next_chunk().await.map_err(publisher_stream_error)? + while let Some(segments) = passthrough_step( + &mut source, + &mut decoder, + &mut encoder, + &mut processor, + ) + .await + .map_err(publisher_stream_error)? { - let decoded = decoder - .decode_chunk(&raw_chunk) - .map_err(publisher_stream_error)?; - if decoded.is_empty() { - continue; - } - if let Some(encoded) = process_and_encode_chunk( - &mut processor, - &mut encoder, - &decoded, - false, - "Failed to process chunk", - ) - .map_err(publisher_stream_error)? - { + for encoded in segments { yield encoded; } } @@ -1334,23 +1369,12 @@ pub async fn stream_publisher_body_async( ) -> Result<(), Report> { let Some(dispatched) = params.dispatched_auction.take() else { if body.is_stream() { - let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, - origin_host: ¶ms.origin_host, - origin_url: ¶ms.origin_url, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - settings, - content_type: ¶ms.content_type, - integration_registry, - ad_slots_script: params.ad_slots_script.as_deref(), - ad_bids_state: ¶ms.ad_bids_state, - }; return process_response_streaming_async( body, output, - &borrowed, - settings.publisher.max_buffered_body_bytes, + params, + settings, + integration_registry, ) .await; } @@ -1378,23 +1402,12 @@ pub async fn stream_publisher_body_async( ) .await; if body.is_stream() { - let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, - origin_host: ¶ms.origin_host, - origin_url: ¶ms.origin_url, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - settings, - content_type: ¶ms.content_type, - integration_registry, - ad_slots_script: params.ad_slots_script.as_deref(), - ad_bids_state: ¶ms.ad_bids_state, - }; return process_response_streaming_async( body, output, - &borrowed, - settings.publisher.max_buffered_body_bytes, + params, + settings, + integration_registry, ) .await; } @@ -1639,14 +1652,14 @@ async fn stream_html_with_auction_hold( ctx: AuctionCollectCtx<'_>, ) -> Result<(), Report> { if body.is_stream() { - let max_raw_body_bytes = ctx.settings.publisher.max_buffered_body_bytes; + let max_body_bytes = ctx.settings.publisher.max_buffered_body_bytes; return body_close_hold_loop_stream( body, output, processor, compression, ctx, - max_raw_body_bytes, + max_body_bytes, ) .await; } @@ -1690,16 +1703,22 @@ async fn stream_html_with_auction_hold( /// Async-pull variant of [`body_close_hold_loop`] for live origin streams. /// -/// Shares [`hold_step_decoded_chunk`] and [`hold_finish_segments`] with the +/// Shares [`hold_step_next_chunk`] and [`hold_finish_segments`] with the /// lazy streaming body built by [`publisher_response_into_streaming_response`], /// so the two async hold paths cannot drift apart. +/// +/// No production caller reaches this today: it is only entered through +/// [`buffer_publisher_response_async`], and the buffered adapters (Axum, +/// Cloudflare, Spin) never produce `Body::Stream` because the publisher fetch +/// is gated on `supports_streaming_responses()`. It is groundwork for those +/// adapters' streaming cutover; Fastly uses the lazy stream instead. async fn body_close_hold_loop_stream( body: EdgeBody, writer: &mut W, processor: &mut P, compression: Compression, ctx: AuctionCollectCtx<'_>, - max_raw_body_bytes: usize, + max_body_bytes: usize, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, @@ -1710,11 +1729,10 @@ async fn body_close_hold_loop_stream( services, settings, } = ctx; - let mut decoder = BodyStreamDecoder::new(compression); + let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); - let mut source = - BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); - let mut state = AuctionHoldState::new(dispatched, telemetry); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); + let mut state = AuctionHoldState::new(DispatchedAuctionGuard::new(dispatched), telemetry); let collect_refs = AuctionHoldCollectRefs { price_granularity, ad_bids_state, @@ -1723,29 +1741,17 @@ async fn body_close_hold_loop_stream( settings, }; - loop { - let raw_chunk = match source.next_chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => break, - Err(err) => { - abandon_hold_auction(&mut state, services, "stream_read_error").await; - return Err(err); - } - }; - let decoded = match decoder.decode_chunk(&raw_chunk) { - Ok(decoded) => decoded, - Err(err) => { - abandon_hold_auction(&mut state, services, "stream_decode_error").await; - return Err(err); - } - }; - if decoded.is_empty() { - continue; - } - for encoded in - hold_step_decoded_chunk(processor, &mut encoder, &decoded, &mut state, &collect_refs) - .await? - { + while let Some(segments) = hold_step_next_chunk( + &mut source, + &mut decoder, + &mut encoder, + processor, + &mut state, + &collect_refs, + ) + .await? + { + for encoded in segments { write_encoded_segment(writer, &encoded)?; } } @@ -2439,6 +2445,11 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. + // + // Streaming is gated on the capability (unlike the asset-proxy path, which + // sets the flag unconditionally and tolerates buffered fallback): adapters + // without streaming support may reject the flag outright rather than + // silently buffering, which would fail every publisher fetch. let mut platform_request = PlatformHttpRequest::new(req, backend_name); if services.http_client().supports_streaming_responses() { platform_request = platform_request.with_stream_response(); @@ -3268,6 +3279,7 @@ pub async fn handle_page_bids( #[cfg(test)] mod tests { + use std::future::Future as _; use std::io::{self, Read as _, Write as _}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -4952,6 +4964,185 @@ mod tests { }); } + fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: content_encoding.to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + } + } + + #[test] + fn stream_publisher_body_async_rejects_truncated_gzip_stream() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = non_html_stream_params("gzip"); + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let truncated = &compressed[..compressed.len() - 3]; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::copy_from_slice( + truncated, + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("truncated gzip stream must fail instead of truncating silently"); + + assert!( + format!("{err:?}").contains("gzip"), + "should surface the gzip finalization failure: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_rejects_truncated_deflate_stream() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = non_html_stream_params("deflate"); + let compressed = + deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + // Cut into the deflate data itself, not just the adler32 trailer. + let truncated = &compressed[..compressed.len() / 2]; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::copy_from_slice( + truncated, + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("truncated deflate stream must fail instead of truncating silently"); + + assert!( + format!("{err:?}").contains("deflate"), + "should surface the deflate finalization failure: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_enforces_decoded_byte_cap() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + // Raw compressed input stays tiny (well under the cap); only the + // decoded expansion exceeds it — the decompression-bomb case the + // raw-byte cap alone cannot catch. + settings.publisher.max_buffered_body_bytes = 1024; + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = non_html_stream_params("gzip"); + let compressed = gzip_encode(&vec![b'a'; 64 * 1024]); + assert!( + compressed.len() < 1024, + "test precondition: compressed input must stay under the raw cap" + ); + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from(compressed)])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("decoded expansion past the cap must fail"); + + assert!( + format!("{err:?}").contains("decoded size exceeded"), + "should report the cumulative decoded cap: {err:?}" + ); + }); + } + + #[test] + fn body_chunk_source_resumes_after_cancelled_poll() { + futures::executor::block_on(async { + let mut pending_once = true; + let mut yielded = false; + let stream = futures::stream::poll_fn(move |cx| { + if pending_once { + pending_once = false; + cx.waker().wake_by_ref(); + return std::task::Poll::Pending; + } + if yielded { + return std::task::Poll::Ready(None); + } + yielded = true; + std::task::Poll::Ready(Some(Ok::<_, io::Error>(bytes::Bytes::from_static( + b"chunk", + )))) + }); + let body = EdgeBody::from_stream(stream); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE); + + { + // Poll the pull future once (Pending), then drop it — + // simulating a cancelled await (select/timeout wrapper). + let mut pull = Box::pin(source.next_chunk()); + let waker = futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + assert!( + pull.as_mut().poll(&mut context).is_pending(), + "first poll should be pending" + ); + } + + let chunk = source + .next_chunk() + .await + .expect("should read after cancelled poll"); + assert_eq!( + chunk.as_deref(), + Some(&b"chunk"[..]), + "cancelled pull must not lose the origin stream" + ); + }); + } + #[test] fn stream_publisher_body_async_processes_stream_with_auction_hold() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 008b4a2d5..0a6178a07 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -51,11 +51,14 @@ pub struct Publisher { /// Defaults to 16 MiB — a conservative cap that prevents Wasm-heap OOM. /// /// Fastly origin bodies are preserved as streams on the publisher path, so - /// this setting is also the cumulative raw-byte cap while the streaming - /// processor decodes and rewrites chunks. Buffered adapters keep using it - /// as the post-rewrite output buffer cap. On the streaming path headers - /// are already committed when the cap trips, so the response is truncated - /// mid-body (with the error logged) rather than replaced with a 5xx. + /// this setting also caps the streaming pipeline twice over: cumulative + /// raw (still compressed) bytes pulled from origin, and cumulative decoded + /// bytes emitted by the decompressor — the latter so a decompression bomb + /// cannot push an unbounded decoded volume through the rewrite pipeline. + /// Buffered adapters keep using it as the post-rewrite output buffer cap. + /// On the streaming path headers are already committed when either cap + /// trips, so the response is truncated mid-body (with the error logged) + /// rather than replaced with a 5xx. /// /// Must be at least 1: a zero-byte cap fails every non-empty buffered /// publisher response at request time, so it is rejected at config diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index ef1a0bc55..963c69daa 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -359,88 +359,177 @@ pub(crate) const STREAM_CHUNK_SIZE: usize = 8192; /// Compressed bytes go in via [`Self::decode_chunk`]; decoded bytes drain /// out of the internal buffer after every push. Write-based decoders are /// used because the async publisher path cannot wrap a blocking `Read`. -pub(crate) enum BodyStreamDecoder { +/// +/// Decoded output is capped cumulatively: the chunk source only bounds raw +/// (still compressed) bytes, and a decompression bomb can expand ~1000x past +/// that, so the decoder enforces its own ceiling on the total bytes it emits. +/// +/// Every codec validates end-of-stream at [`Self::finish`] so a truncated +/// origin body errors instead of silently truncating the page: gzip via its +/// trailer checksum, brotli via `close()`, and deflate via an explicit +/// [`flate2::Status::StreamEnd`] check (`write::ZlibDecoder` accepts +/// truncated input silently, so the deflate arm drives [`flate2::Decompress`] +/// directly). +pub(crate) struct BodyStreamDecoder { + codec: BodyStreamDecoderCodec, + decoded_bytes: usize, + max_decoded_bytes: usize, +} + +enum BodyStreamDecoderCodec { None, Gzip(flate2::write::GzDecoder>), - Deflate(flate2::write::ZlibDecoder>), + Deflate(DeflateStreamDecoder), Brotli(Box>>), } +/// Streaming zlib decoder that tracks whether the stream reached its end +/// marker, so truncated deflate bodies fail at finalization. +struct DeflateStreamDecoder { + decompress: flate2::Decompress, + stream_ended: bool, +} + +impl DeflateStreamDecoder { + fn new() -> Self { + Self { + decompress: flate2::Decompress::new(true), + stream_ended: false, + } + } + + fn decode(&mut self, chunk: &[u8]) -> Result, Report> { + let mut output = Vec::with_capacity(STREAM_CHUNK_SIZE); + let mut offset = 0usize; + // Trailing bytes after the zlib end marker are ignored, matching the + // read-based decoder used by the buffered pipeline. + while offset < chunk.len() && !self.stream_ended { + if output.len() == output.capacity() { + output.reserve(STREAM_CHUNK_SIZE); + } + let before_in = self.decompress.total_in(); + let before_out = self.decompress.total_out(); + let status = self + .decompress + .decompress_vec(&chunk[offset..], &mut output, flate2::FlushDecompress::None) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode deflate publisher body chunk".to_string(), + })?; + let consumed = (self.decompress.total_in() - before_in) as usize; + let produced = (self.decompress.total_out() - before_out) as usize; + offset += consumed; + match status { + flate2::Status::StreamEnd => self.stream_ended = true, + flate2::Status::Ok | flate2::Status::BufError => { + if consumed == 0 && produced == 0 && output.len() < output.capacity() { + return Err(Report::new(TrustedServerError::Proxy { + message: "deflate publisher body decoder made no progress".to_string(), + })); + } + } + } + } + Ok(output) + } +} + impl BodyStreamDecoder { - pub(crate) fn new(compression: Compression) -> Self { - match compression { - Compression::None => Self::None, - Compression::Gzip => Self::Gzip(flate2::write::GzDecoder::new(Vec::new())), - Compression::Deflate => Self::Deflate(flate2::write::ZlibDecoder::new(Vec::new())), - Compression::Brotli => Self::Brotli(Box::new(brotli::DecompressorWriter::new( - Vec::new(), - STREAM_CHUNK_SIZE, - ))), + pub(crate) fn new(compression: Compression, max_decoded_bytes: usize) -> Self { + let codec = match compression { + Compression::None => BodyStreamDecoderCodec::None, + Compression::Gzip => { + BodyStreamDecoderCodec::Gzip(flate2::write::GzDecoder::new(Vec::new())) + } + Compression::Deflate => BodyStreamDecoderCodec::Deflate(DeflateStreamDecoder::new()), + Compression::Brotli => BodyStreamDecoderCodec::Brotli(Box::new( + brotli::DecompressorWriter::new(Vec::new(), STREAM_CHUNK_SIZE), + )), + }; + Self { + codec, + decoded_bytes: 0, + max_decoded_bytes, } } pub(crate) fn decode_chunk( &mut self, - chunk: &[u8], - ) -> Result, Report> { - match self { - Self::None => Ok(chunk.to_vec()), - Self::Gzip(decoder) => { + chunk: bytes::Bytes, + ) -> Result> { + let decoded = match &mut self.codec { + BodyStreamDecoderCodec::None => chunk, + BodyStreamDecoderCodec::Gzip(decoder) => { decoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode gzip publisher body chunk".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + bytes::Bytes::from(std::mem::take(decoder.get_mut())) } - Self::Deflate(decoder) => { + BodyStreamDecoderCodec::Deflate(decoder) => bytes::Bytes::from(decoder.decode(&chunk)?), + BodyStreamDecoderCodec::Brotli(decoder) => { decoder - .write_all(chunk) - .change_context(TrustedServerError::Proxy { - message: "Failed to decode deflate publisher body chunk".to_string(), - })?; - Ok(std::mem::take(decoder.get_mut())) - } - Self::Brotli(decoder) => { - decoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode brotli publisher body chunk".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + bytes::Bytes::from(std::mem::take(decoder.get_mut())) } - } + }; + self.track_decoded(decoded.len())?; + Ok(decoded) } pub(crate) fn finish(&mut self) -> Result, Report> { - match self { - Self::None => Ok(Vec::new()), - Self::Gzip(decoder) => { + let tail = match &mut self.codec { + BodyStreamDecoderCodec::None => Vec::new(), + BodyStreamDecoderCodec::Gzip(decoder) => { decoder .try_finish() .change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip publisher body decoder".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + std::mem::take(decoder.get_mut()) } - Self::Deflate(decoder) => { - decoder - .try_finish() - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize deflate publisher body decoder".to_string(), - })?; - Ok(std::mem::take(decoder.get_mut())) + BodyStreamDecoderCodec::Deflate(decoder) => { + if !decoder.stream_ended { + return Err(Report::new(TrustedServerError::Proxy { + message: + "Failed to finalize deflate publisher body decoder: truncated stream" + .to_string(), + })); + } + Vec::new() } - Self::Brotli(decoder) => { + BodyStreamDecoderCodec::Brotli(decoder) => { // `close()` (not `flush()`): flush accepts a truncated brotli // stream silently, while close validates end-of-stream and // errors on incomplete input, matching the gzip/deflate arms. decoder.close().change_context(TrustedServerError::Proxy { message: "Failed to finalize brotli publisher body decoder".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + std::mem::take(decoder.get_mut()) } + }; + self.track_decoded(tail.len())?; + Ok(tail) + } + + fn track_decoded(&mut self, len: usize) -> Result<(), Report> { + self.decoded_bytes = self.decoded_bytes.checked_add(len).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin body decoded byte count overflowed".to_string(), + }) + })?; + if self.decoded_bytes > self.max_decoded_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "publisher origin body decoded size exceeded {}-byte streaming limit", + self.max_decoded_bytes + ), + })); } + Ok(()) } } @@ -482,13 +571,14 @@ impl BodyStreamEncoder { pub(crate) fn encode_chunk( &mut self, - chunk: &[u8], + chunk: Vec, ) -> Result, Report> { match self { - Self::None => Ok(chunk.to_vec()), + // Identity encoding passes the processed chunk through untouched. + Self::None => Ok(chunk), Self::Gzip(encoder) => { encoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to encode gzip publisher body chunk".to_string(), })?; @@ -496,7 +586,7 @@ impl BodyStreamEncoder { } Self::Deflate(encoder) => { encoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to encode deflate publisher body chunk".to_string(), })?; @@ -504,7 +594,7 @@ impl BodyStreamEncoder { } Self::Brotli(encoder) => { encoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to encode brotli publisher body chunk".to_string(), })?; @@ -513,29 +603,18 @@ impl BodyStreamEncoder { } } + /// Emits the encoder trailer. Consumes the codec state (the encoder + /// becomes identity afterwards); terminal — call once at end of stream. pub(crate) fn finish(&mut self) -> Result, Report> { - match self { + match std::mem::replace(self, Self::None) { Self::None => Ok(Vec::new()), - Self::Gzip(encoder) => { - encoder - .try_finish() - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize gzip publisher body encoder".to_string(), - })?; - Ok(std::mem::take(encoder.get_mut())) - } - Self::Deflate(encoder) => { - encoder - .try_finish() - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize deflate publisher body encoder".to_string(), - })?; - Ok(std::mem::take(encoder.get_mut())) - } - Self::Brotli(encoder) => { - let encoder = std::mem::replace(encoder, Box::new(new_brotli_vec_encoder())); - Ok((*encoder).into_inner()) - } + Self::Gzip(encoder) => encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip publisher body encoder".to_string(), + }), + Self::Deflate(encoder) => encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body encoder".to_string(), + }), + Self::Brotli(encoder) => Ok((*encoder).into_inner()), } } } @@ -545,6 +624,86 @@ mod tests { use super::*; use crate::streaming_replacer::{Replacement, StreamingReplacer}; + #[test] + fn body_stream_decoder_enforces_cumulative_decoded_cap() { + let compressed = { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(&vec![b'a'; 64 * 1024]) + .expect("should write gzip test input"); + encoder.finish().expect("should finish gzip encoding") + }; + assert!( + compressed.len() < 1024, + "test precondition: compressed input must stay small" + ); + let mut decoder = BodyStreamDecoder::new(Compression::Gzip, 1024); + + let err = decoder + .decode_chunk(bytes::Bytes::from(compressed)) + .expect_err("decoded expansion past the cap must fail"); + + assert!( + format!("{err:?}").contains("decoded size exceeded"), + "should report the cumulative decoded cap: {err:?}" + ); + } + + #[test] + fn body_stream_decoder_rejects_truncated_deflate_stream() { + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(b"deflate payload that spans more than one deflate block boundary") + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let truncated = &compressed[..compressed.len() / 2]; + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + decoder + .decode_chunk(bytes::Bytes::copy_from_slice(truncated)) + .expect("partial deflate input should decode incrementally"); + + let err = decoder + .finish() + .expect_err("truncated deflate stream must fail at finalization"); + + assert!( + format!("{err:?}").contains("truncated stream"), + "should report the missing deflate end marker: {err:?}" + ); + } + + #[test] + fn body_stream_decoder_ignores_deflate_trailing_bytes() { + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(b"deflate payload") + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let mut with_trailing = compressed; + with_trailing.extend_from_slice(b"junk"); + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + + let decoded = decoder + .decode_chunk(bytes::Bytes::from(with_trailing)) + .expect("complete deflate stream should decode"); + decoder + .finish() + .expect("trailing bytes after the end marker should be ignored"); + + assert_eq!( + decoded.as_ref(), + b"deflate payload", + "should decode the payload and drop trailing junk" + ); + } + /// Verify that `lol_html` fragments text nodes when input chunks split /// mid-text-node. Script rewriters must be fragment-safe — they accumulate /// text fragments internally until `is_last_in_text_node` is true. From 2c64f3c613e4079ac741fab75762376c97573ca1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 23:02:08 +0530 Subject: [PATCH 004/494] Emit processor_init_error telemetry on streaming finalize failure The buffered finalizer abandons a dispatched auction with processor_init_error telemetry when HTML processor construction fails; the streaming finalizer dropped the in-flight SSP responses silently. Make publisher_response_into_streaming_response async and emit the same abandonment before returning the construction error. --- .../trusted-server-adapter-fastly/src/app.rs | 19 +++++----- crates/trusted-server-core/src/publisher.rs | 35 ++++++++++++++----- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ae9a2749b..b68897077 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -795,14 +795,17 @@ async fn dispatch_fallback( ) .await { - Ok(pub_response) => publisher_response_into_streaming_response( - pub_response, - &method, - Arc::clone(&state.settings), - state.registry.as_ref(), - Arc::clone(&state.orchestrator), - publisher_services.clone(), - ), + Ok(pub_response) => { + publisher_response_into_streaming_response( + pub_response, + &method, + Arc::clone(&state.settings), + state.registry.as_ref(), + Arc::clone(&state.orchestrator), + publisher_services.clone(), + ) + .await + } Err(e) => Err(e), } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index fe6467e12..4d7e38cf9 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1101,9 +1101,10 @@ pub async fn buffer_publisher_response_async( /// /// # Errors /// -/// Returns an error if processor construction fails before the streaming body is -/// created. -pub fn publisher_response_into_streaming_response( +/// Returns an error if processor construction fails before the streaming body +/// is created; a dispatched auction is abandoned with `processor_init_error` +/// telemetry first, matching the buffered finalizer. +pub async fn publisher_response_into_streaming_response( publisher_response: PublisherResponse, method: &Method, settings: Arc, @@ -1142,7 +1143,25 @@ pub fn publisher_response_into_streaming_response( response.headers_mut().remove(header::CONTENT_LENGTH); let mut params = *params; let mut processor = - PublisherBodyProcessor::new(¶ms, &settings, integration_registry)?; + match PublisherBodyProcessor::new(¶ms, &settings, integration_registry) { + Ok(processor) => processor, + Err(err) => { + // Parity with the buffered finalizer: a processor + // construction failure abandons the dispatched auction + // with telemetry instead of dropping the in-flight SSP + // responses silently. + if let Some(dispatched) = params.dispatched_auction.take() { + emit_abandoned_auction( + &services, + params.auction_observation.take(), + dispatched, + "processor_init_error", + ) + .await; + } + return Err(err); + } + }; // The guard is created before the lazy stream so an auction whose // response body is dropped unpolled still logs the loss. let dispatched_auction = params.dispatched_auction.take().map(|dispatched| { @@ -5292,14 +5311,14 @@ mod tests { params: Box::new(params), }; - let response = publisher_response_into_streaming_response( + let response = futures::executor::block_on(publisher_response_into_streaming_response( publisher_response, &Method::GET, Arc::clone(&settings), registry.as_ref(), orchestrator, services, - ) + )) .expect("should build streaming response"); assert!( @@ -5458,14 +5477,14 @@ mod tests { params: Box::new(params), }; - let response = publisher_response_into_streaming_response( + let response = futures::executor::block_on(publisher_response_into_streaming_response( publisher_response, &Method::GET, Arc::clone(&settings), registry.as_ref(), orchestrator, services, - ) + )) .expect("should build streaming response"); let output = futures::executor::block_on( From b95813ccc9a5c6542eebd38dd9b63a2b3ba6200f Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 09:29:00 -0500 Subject: [PATCH 005/494] Fix Prebid User ID diagnostics Publisher-specific bundles need diagnostics based on the modules they actually contain. Otherwise, missing identity integrations can be hidden by the default preset. Refresh the comparison when auctions begin so late publisher configuration is visible without repeating warnings. Resolves: #886 --- .../lib/build-prebid-external.mjs | 10 +++- .../prebid/_user_ids.generated.ts | 5 +- .../lib/src/integrations/prebid/index.ts | 42 ++++++++--------- .../integrations/prebid/user_id_modules.json | 12 +++++ .../lib/test/build-prebid-external.test.mjs | 12 ++++- .../test/integrations/prebid/index.test.ts | 47 ++++++++++++++++++- .../prebid/user_id_modules.test.ts | 22 ++++++++- 7 files changed, 119 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index e3bb5ab3c..4e89723ed 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -107,7 +107,7 @@ function validateUserIdImport(entry) { } } -function writeGeneratedModule(filePath, title, moduleNames, imports) { +function writeGeneratedModule(filePath, title, moduleNames, imports, exports = []) { const content = [ '// Auto-generated by build-prebid-external.mjs.', '//', @@ -115,12 +115,17 @@ function writeGeneratedModule(filePath, title, moduleNames, imports) { `// Modules: ${moduleNames.join(', ')}`, '', ...imports, + ...(exports.length > 0 ? ['', ...exports] : []), '', ].join('\n'); fs.writeFileSync(filePath, content); } +export function renderIncludedUserIdModulesExport(moduleNames) { + return `export const INCLUDED_PREBID_USER_ID_MODULES = ${JSON.stringify(moduleNames)};`; +} + function generateAdapterImports(adapterNames, adaptersFile) { const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); const imports = []; @@ -165,7 +170,8 @@ function generateUserIdImports(requestedModules, userIdsFile) { userIdsFile, '// External Prebid bundle User ID module imports.', moduleNames, - imports + imports, + [renderIncludedUserIdModulesExport(moduleNames)] ); return moduleNames; } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts index e9fa55f7d..e7c0112a9 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -1,6 +1,7 @@ // Placeholder for generated Prebid User ID module imports. // // build-prebid-external.mjs aliases this module to a temporary file containing -// publisher-specific imports during external bundle generation. +// publisher-specific imports and the corresponding module-name list during +// external bundle generation. -export {}; +export const INCLUDED_PREBID_USER_ID_MODULES: string[] = []; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index be839d8fc..c6cf97cfe 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,14 +25,14 @@ import 'prebid.js/modules/userId.js'; // shim leaves its bids untouched and the corresponding adapter handles them // natively in the browser. import './_adapters.generated'; -import './_user_ids.generated'; +import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; -import { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; @@ -139,7 +139,7 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort(); const coveredConfigNames = new Set( PREBID_USER_ID_MODULE_REGISTRY.filter((entry) => - DEFAULT_PREBID_USER_ID_MODULES.includes(entry.moduleName) + INCLUDED_PREBID_USER_ID_MODULES.includes(entry.moduleName) ).flatMap((entry) => entry.configNames) ); const missingConfiguredUserIdNames = configuredUserIdNames.filter( @@ -147,15 +147,20 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { ); const diagnostics: PrebidUserIdDiagnostics = { - includedModules: [...DEFAULT_PREBID_USER_ID_MODULES], + includedModules: [...INCLUDED_PREBID_USER_ID_MODULES], configuredUserIdNames, missingConfiguredUserIdNames, }; + const previouslyMissingConfiguredUserIdNames = new Set(); if (typeof window !== 'undefined') { const tsjsWindow = window as typeof window & { __tsjs_prebid_diagnostics?: { userIdModules?: PrebidUserIdDiagnostics }; }; + for (const name of tsjsWindow.__tsjs_prebid_diagnostics?.userIdModules + ?.missingConfiguredUserIdNames ?? []) { + previouslyMissingConfiguredUserIdNames.add(name); + } tsjsWindow.__tsjs_prebid_diagnostics = { ...(tsjsWindow.__tsjs_prebid_diagnostics ?? {}), userIdModules: diagnostics, @@ -163,9 +168,11 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { } for (const name of missingConfiguredUserIdNames) { - log.warn( - `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` - ); + if (!previouslyMissingConfiguredUserIdNames.has(name)) { + log.warn( + `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` + ); + } } return diagnostics; @@ -559,6 +566,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // client-side bidders are left untouched. pbjs.requestBids = function (requestObj?: Parameters[0]) { log.debug('[tsjs-prebid] requestBids called'); + recordUserIdModuleDiagnostics(); const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -811,25 +819,13 @@ export function installRefreshHandler(timeoutMs = 1500): void { } /** - * Configure Prebid.js userID modules for identity warm-up. - * - * Runs post-window.load (called from installPrebidNpm after setup). - * Writes identity tokens to 1P cookies so the next server-side request - * can harvest them for EC graph enrichment. + * Configure identity sync behavior for the generated Prebid User ID modules. * - * **Current state:** This function only configures `pbjs.userSync` settings. - * It does NOT import or register any userID modules. Actual module imports - * (ID5, sharedID, LiveRamp ATS, Lockr) must be added to this bundle explicitly - * — there is currently no `_userIdModules.generated.ts` build step. - * Track as Phase B follow-up: add `TSJS_PREBID_USER_ID_MODULES` handling to - * `build-all.mjs` (similar to `TSJS_PREBID_ADAPTERS`) and import generated file. + * The external bundle generator statically imports the selected modules through + * `_user_ids.generated.ts`. This post-window-load configuration controls when + * those modules synchronize identities; it does not select or register modules. */ export function installUserIdModules(): void { - // NOTE: No userID module imports exist yet. `_userIdModules.generated.ts` and - // `TSJS_PREBID_USER_ID_MODULES` handling in `build-all.mjs` are not implemented. - // This function only configures pbjs.userSync settings; actual module registration - // requires the Phase B follow-up described in the docblock above. - // Configure sync behavior so modules will run post-window.load when added. try { pbjs.setConfig({ userSync: { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json index 10f244f31..a4fd58dbe 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json @@ -58,6 +58,18 @@ "importPath": "prebid.js/modules/liveIntentIdSystem.js", "notes": "Imported through a local ESM shim because the public Prebid wrapper contains CommonJS require()." }, + { + "moduleName": "lockrAIMIdSystem", + "configNames": ["lockrAIMId"], + "eidSources": [], + "importPath": "prebid.js/modules/lockrAIMIdSystem.js" + }, + { + "moduleName": "pairIdSystem", + "configNames": ["pairId"], + "eidSources": ["google.com"], + "importPath": "prebid.js/modules/pairIdSystem.js" + }, { "moduleName": "pubProvidedIdSystem", "configNames": ["pubProvidedId"], diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 609bdba91..10702f914 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -3,7 +3,11 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { deriveBundleMetadata, parseArgs } from '../build-prebid-external.mjs'; +import { + deriveBundleMetadata, + parseArgs, + renderIncludedUserIdModulesExport, +} from '../build-prebid-external.mjs'; describe('build-prebid-external metadata', () => { it('derives filename, sha256, and SRI from exact bundle bytes', () => { @@ -18,6 +22,12 @@ describe('build-prebid-external metadata', () => { }); }); + it('renders the exact selected User ID modules for runtime diagnostics', () => { + expect(renderIncludedUserIdModulesExport(['liveIntentIdSystem', 'pairIdSystem'])).toBe( + 'export const INCLUDED_PREBID_USER_ID_MODULES = ["liveIntentIdSystem","pairIdSystem"];' + ); + }); + it('resolves relative output paths against the current working directory', () => { const parsed = parseArgs(['--adapters', 'rubicon', '--out', 'dist/prebid']); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c4..e6b5fd354 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -7,6 +7,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockGetConfig, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -19,12 +20,14 @@ const { const mockGetUserIdsAsEids = vi.fn( () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); + const mockGetConfig = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, + getConfig: mockGetConfig, adUnits: [] as any[], }; const mockAdapterManager = { @@ -36,6 +39,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockGetConfig, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -53,9 +57,11 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); -// Mock the build-generated side-effect imports (no-op in tests) +// Mock the build-generated imports in tests. vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); -vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({})); +vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({ + INCLUDED_PREBID_USER_ID_MODULES: ['sharedIdSystem'], +})); import { collectBidders, @@ -65,6 +71,7 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import { log } from '../../../src/core/log'; describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { @@ -207,8 +214,14 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); + mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete (window as any).__tsjs_prebid; + delete (window as any).__tsjs_prebid_diagnostics; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('registers the trustedServer bid adapter', () => { @@ -251,6 +264,36 @@ describe('prebid/installPrebidNpm', () => { expect(mockProcessQueue).toHaveBeenCalledTimes(1); }); + it('reports the User ID modules selected by the generated bundle', () => { + installPrebidNpm(); + + expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: [], + missingConfiguredUserIdNames: [], + }); + }); + + it('refreshes late User ID config without repeating missing-module warnings', () => { + installPrebidNpm(); + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} + ); + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + mockPbjs.requestBids({ adUnits: [] }); + mockPbjs.requestBids({ adUnits: [] }); + + expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: ['pairId', 'sharedId'], + missingConfiguredUserIdNames: ['pairId'], + }); + expect( + warnSpy.mock.calls.filter(([message]) => String(message).includes('"pairId"')) + ).toHaveLength(1); + }); + it('returns the pbjs instance', () => { const result = installPrebidNpm(); expect(result).toBe(mockPbjs); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts index f011f294d..2832a4082 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { resolvePrebidUserIdModulesFromEids } from '../../../src/integrations/prebid/user_id_modules'; +import { + knownUserIdConfigNames, + resolvePrebidUserIdModulesFromEids, +} from '../../../src/integrations/prebid/user_id_modules'; const sampleEids = [ { source: 'yahoo.com', uids: [{ id: 'connect-id', atype: 3 }] }, @@ -65,6 +68,23 @@ describe('prebid user ID module registry', () => { }); }); + it('exposes config names for modules that do not map EID sources', () => { + expect(knownUserIdConfigNames()).toEqual( + expect.arrayContaining(['lockrAIMId', 'pubProvidedId']) + ); + }); + + it('maps the Google PAIR EID source to pairIdSystem', () => { + const result = resolvePrebidUserIdModulesFromEids([ + { source: 'google.com', uids: [{ id: 'pair-id' }] }, + ]); + + expect(result).toEqual({ + modules: ['userId', 'pairIdSystem'], + missingSources: [], + }); + }); + it('maps unknown LiveIntent provider-backed sources to liveIntentIdSystem', () => { const result = resolvePrebidUserIdModulesFromEids([ { From e94430eb72afe9feffdba612e01ec63de5ed8934 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 09:50:08 -0500 Subject: [PATCH 006/494] Order Prebid imports for lint --- crates/trusted-server-js/lib/src/integrations/prebid/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index c6cf97cfe..a4b1ccff2 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,13 +25,13 @@ import 'prebid.js/modules/userId.js'; // shim leaves its bids untouched and the corresponding adapter handles them // natively in the browser. import './_adapters.generated'; -import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; +import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; From b56adcb39ade3902ff8c131bb8ac7397fbfff74c Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 10:28:03 -0500 Subject: [PATCH 007/494] Preserve vendor-specific OpenRTB atype values --- .../src/auction/endpoints.rs | 22 +++++++- crates/trusted-server-core/src/ec/eids.rs | 16 +++++- .../trusted-server-core/src/ec/prebid_eids.rs | 48 ++++++++++++++--- crates/trusted-server-core/src/ec/registry.rs | 2 +- .../src/integrations/prebid.rs | 18 ++++++- crates/trusted-server-core/src/openrtb.rs | 23 ++++++++- crates/trusted-server-core/src/settings.rs | 51 +++++++++++++++++-- .../lib/src/integrations/prebid/index.ts | 5 +- .../lib/test/build-prebid-external.test.mjs | 32 ++++++++++++ .../test/integrations/prebid/index.test.ts | 10 +++- trusted-server.example.toml | 1 + 11 files changed, 206 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 83c7df349..2833e63eb 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -476,7 +476,7 @@ fn parse_client_auction_uid(raw: &JsonValue) -> Option { let atype = uid .get("atype") .and_then(JsonValue::as_u64) - .and_then(|atype| u8::try_from(atype).ok()); + .and_then(|atype| i32::try_from(atype).ok()); let ext = match uid.get("ext") { Some(JsonValue::Object(_)) => uid.get("ext").cloned(), @@ -1057,6 +1057,24 @@ mod tests { assert_eq!(parsed[0].uids[0].id, "valid", "should keep valid UID"); } + #[test] + fn parse_client_auction_eids_preserves_pair_atype() { + let raw = json!([ + { + "source": "google.com", + "uids": [{ "id": "pair-id", "atype": 571187 }] + } + ]); + + let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse PAIR EID"); + + assert_eq!( + parsed[0].uids[0].atype, + Some(571187), + "should preserve PAIR's vendor-specific atype" + ); + } + #[test] fn parse_client_auction_eids_preserves_uid_ext_and_sanitizes_invalid_atype() { let raw = json!([ @@ -1070,7 +1088,7 @@ mod tests { }, { "id": "uid-bad-atype", - "atype": 999, + "atype": 2_147_483_648_u64, "ext": { "keep": true } }, { diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1dd8b1795..2c01bf852 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -25,7 +25,7 @@ pub struct ResolvedPartnerId { /// The synced user ID value. pub uid: String, /// `OpenRTB` agent type for this partner's identifiers. - pub openrtb_atype: u8, + pub openrtb_atype: i32, } /// Resolves source-domain keyed IDs from a KV entry against the partner registry. @@ -214,17 +214,29 @@ mod tests { source_domain: "id5-sync.com".to_owned(), openrtb_atype: 1, }, + ResolvedPartnerId { + uid: "pair-id".to_owned(), + source_domain: "google.com".to_owned(), + openrtb_atype: 571187, + }, ]; let eids = to_eids(&resolved); - assert_eq!(eids.len(), 2, "should produce one EID per resolved partner"); + assert_eq!(eids.len(), 3, "should produce one EID per resolved partner"); assert_eq!(eids[0].source, "liveramp.com"); assert_eq!(eids[0].uids[0].id, "LR_xyz"); assert_eq!(eids[0].uids[0].atype, Some(3)); assert_eq!(eids[1].source, "id5-sync.com"); assert_eq!(eids[1].uids[0].id, "ID5_abc"); assert_eq!(eids[1].uids[0].atype, Some(1)); + assert_eq!(eids[2].source, "google.com", "should preserve PAIR source"); + assert_eq!(eids[2].uids[0].id, "pair-id", "should preserve PAIR ID"); + assert_eq!( + eids[2].uids[0].atype, + Some(571187), + "should preserve PAIR vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 22620e599..dc95be1aa 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -33,7 +33,7 @@ struct LegacyCookieEid { dead_code, reason = "legacy cookie field is deserialized for compatibility but not emitted" )] - atype: u8, + atype: i32, } /// OpenRTB-style `ts-eids` cookie entry. @@ -48,7 +48,7 @@ struct StructuredCookieEid { struct StructuredCookieUid { id: String, #[serde(default)] - atype: Option, + atype: Option, #[serde(default)] ext: Option, } @@ -318,6 +318,7 @@ fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { return None; } + let atype = uid.atype.filter(|atype| *atype >= 0); let ext = match uid.ext { Some(JsonValue::Object(_)) => uid.ext, _ => None, @@ -325,7 +326,7 @@ fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { Some(Uid { id: uid.id, - atype: uid.atype, + atype, ext, }) } @@ -338,7 +339,7 @@ fn legacy_cookie_eids_to_openrtb(entries: Vec) -> Vec { source: entry.source, uids: vec![Uid { id: entry.id, - atype: Some(entry.atype), + atype: (entry.atype >= 0).then_some(entry.atype), ext: None, }], }) @@ -407,15 +408,25 @@ mod tests { let eids = vec![ json!({"source": "id5-sync.com", "id": "ID5_abc", "atype": 1}), json!({"source": "liveramp.com", "id": "LR_xyz", "atype": 3}), + json!({"source": "google.com", "id": "pair-id", "atype": 571187}), ]; let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); - assert_eq!(decoded.len(), 2, "should parse both EIDs"); + assert_eq!(decoded.len(), 3, "should parse all EIDs"); assert_eq!(decoded[0].source, "id5-sync.com"); assert_eq!(decoded[0].uids[0].id, "ID5_abc"); assert_eq!(decoded[1].source, "liveramp.com"); assert_eq!(decoded[1].uids[0].id, "LR_xyz"); + assert_eq!( + decoded[2].source, "google.com", + "should preserve PAIR source" + ); + assert_eq!( + decoded[2].uids[0].atype, + Some(571187), + "should preserve PAIR vendor-specific atype" + ); } #[test] @@ -424,7 +435,8 @@ mod tests { "source": "sharedid.org", "uids": [ {"id": "shared_123", "atype": 3}, - {"id": "shared_456", "ext": {"provider": "example"}} + {"id": "shared_456", "ext": {"provider": "example"}}, + {"id": "shared_invalid", "atype": -1} ] })]; let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); @@ -432,7 +444,7 @@ mod tests { let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); assert_eq!(decoded.len(), 1, "should parse one structured EID entry"); assert_eq!(decoded[0].source, "sharedid.org"); - assert_eq!(decoded[0].uids.len(), 2, "should preserve multiple UIDs"); + assert_eq!(decoded[0].uids.len(), 3, "should preserve multiple UIDs"); assert_eq!(decoded[0].uids[0].id, "shared_123"); assert_eq!(decoded[0].uids[0].atype, Some(3)); assert_eq!( @@ -440,6 +452,28 @@ mod tests { Some(json!({"provider": "example"})), "should preserve UID ext objects" ); + assert_eq!( + decoded[0].uids[2].atype, None, + "should drop negative atype values" + ); + } + + #[test] + fn parse_prebid_eids_cookie_preserves_pair_atype() { + let encoded = encode_json(&json!([ + { + "source": "google.com", + "uids": [{ "id": "pair-id", "atype": 571187 }] + } + ])); + + let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode PAIR EID"); + + assert_eq!( + decoded[0].uids[0].atype, + Some(571187), + "should preserve PAIR's vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index 6b688d30e..8532de03b 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -25,7 +25,7 @@ pub struct PartnerConfig { /// Canonical `OpenRTB` EID source domain and EC KV `ids` key. pub source_domain: String, /// `OpenRTB` `atype` value. - pub openrtb_atype: u8, + pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. pub bidstream_enabled: bool, /// SHA-256 hex of the partner's API token (precomputed at startup). diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a85..acfef5727 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -4654,6 +4654,14 @@ external_bundle_sri = "sha384-AAAA" ext: None, }], }, + crate::openrtb::Eid { + source: "google.com".to_owned(), + uids: vec![crate::openrtb::Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }, ]); let settings = make_settings(); @@ -4670,7 +4678,7 @@ external_bundle_sri = "sha384-AAAA" let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); let ext_eids = &serialized["user"]["ext"]["eids"]; assert!(ext_eids.is_array(), "should populate user.ext.eids"); - assert_eq!(ext_eids.as_array().unwrap().len(), 2, "should have 2 EIDs"); + assert_eq!(ext_eids.as_array().unwrap().len(), 3, "should have 3 EIDs"); assert_eq!( ext_eids[0]["source"], "liveramp.com", "should include liveramp EID" @@ -4679,6 +4687,14 @@ external_bundle_sri = "sha384-AAAA" ext_eids[1]["source"], "id5-sync.com", "should include id5 EID" ); + assert_eq!( + ext_eids[2]["source"], "google.com", + "should include PAIR EID" + ); + assert_eq!( + ext_eids[2]["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index df99f5653..65237ce0e 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -80,9 +80,9 @@ pub struct Eid { pub struct Uid { /// The identifier value. pub id: String, - /// Agent type: 1 = cookie/device, 2 = person, 3 = user-provided. + /// `OpenRTB` agent type, including vendor-specific values such as PAIR's `571187`. #[serde(skip_serializing_if = "Option::is_none")] - pub atype: Option, + pub atype: Option, /// Provider-specific extension data. #[serde(skip_serializing_if = "Option::is_none")] pub ext: Option, @@ -400,4 +400,23 @@ mod tests { "ext should be omitted when None" ); } + + #[test] + fn eid_serializes_vendor_specific_atype() { + let eid = Eid { + source: "google.com".to_owned(), + uids: vec![Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize"); + + assert_eq!( + serialized["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); + } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a546..aaacb29df 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -299,12 +299,13 @@ pub struct EcPartner { /// This normalized domain is also the canonical EC KV `ids` map key. #[validate(custom(function = EcPartner::validate_source_domain))] pub source_domain: String, - /// `OpenRTB` `atype` value (typically 3). + /// `OpenRTB` `atype` value, including vendor-specific values such as PAIR's `571187`. #[serde( default = "EcPartner::default_openrtb_atype", deserialize_with = "from_value_or_str" )] - pub openrtb_atype: u8, + #[validate(range(min = 0, message = "must be a non-negative OpenRTB agent type"))] + pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. #[serde(default, deserialize_with = "from_value_or_str")] pub bidstream_enabled: bool, @@ -417,7 +418,7 @@ impl EcPartner { } #[must_use] - pub const fn default_openrtb_atype() -> u8 { + pub const fn default_openrtb_atype() -> i32 { 3 } @@ -2808,6 +2809,46 @@ mod tests { } } + #[test] + fn validate_accepts_vendor_specific_ec_partner_atype() { + let toml_str = format!( + r#"{} + [[ec.partners]] + name = "PAIR Partner" + source_domain = "google.com" + openrtb_atype = 571187 + api_token = "test-vendor-token-32-bytes-minimum" + "#, + crate_test_settings_str(), + ); + + let settings = Settings::from_toml(&toml_str) + .expect("should accept vendor-specific OpenRTB agent type"); + + assert_eq!( + settings.ec.partners[0].openrtb_atype, 571187, + "should preserve PAIR's vendor-specific atype" + ); + } + + #[test] + fn validate_rejects_negative_ec_partner_atype() { + let toml_str = format!( + r#"{} + [[ec.partners]] + name = "Invalid Partner" + source_domain = "partner.example.com" + openrtb_atype = -1 + api_token = "test-vendor-token-32-bytes-minimum" + "#, + crate_test_settings_str(), + ); + + let result = Settings::from_toml(&toml_str); + + assert!(result.is_err(), "should reject negative OpenRTB agent type"); + } + #[test] fn validate_accepts_origin_host_header_override() { let toml_str = crate_test_settings_str().replace( @@ -3647,7 +3688,7 @@ origin_host_header_overide = "www.example.com""#, (origin_key, Some("https://origin.test-publisher.com")), (partner_0_name_key, Some("Env Partner 0")), (partner_0_source_domain_key, Some("envpartner0.example.com")), - (partner_0_openrtb_atype_key, Some("1")), + (partner_0_openrtb_atype_key, Some("571187")), (partner_0_bidstream_enabled_key, Some("true")), (partner_0_api_token_key, Some("env-token-0")), (partner_1_name_key, Some("Env Partner 1")), @@ -3666,7 +3707,7 @@ origin_host_header_overide = "www.example.com""#, settings.ec.partners[0].source_domain, "envpartner0.example.com" ); - assert_eq!(settings.ec.partners[0].openrtb_atype, 1); + assert_eq!(settings.ec.partners[0].openrtb_atype, 571187); assert!(settings.ec.partners[0].bidstream_enabled); assert_eq!(settings.ec.partners[0].api_token.expose(), "env-token-0"); assert_eq!(settings.ec.partners[1].name, "Env Partner 1"); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index a4b1ccff2..342e4038d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -35,6 +35,9 @@ import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; +// OpenRTB permits vendor-specific agent types; PAIR uses 571187. +// Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. +const MAX_OPENRTB_ATYPE = 2_147_483_647; const BIDDER_PARAMS_KEY = 'bidderParams'; const ZONE_KEY = 'zone'; const TS_REFRESH_TARGETING_KEYS = [ @@ -281,7 +284,7 @@ function sanitizeAuctionUid(uid: { typeof uid.atype === 'number' && Number.isInteger(uid.atype) && uid.atype >= 0 && - uid.atype <= 255 + uid.atype <= MAX_OPENRTB_ATYPE ) { sanitizedUid.atype = uid.atype; } diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 10702f914..a1c77c47d 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -1,10 +1,15 @@ +// @vitest-environment node + import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { deriveBundleMetadata, + main, parseArgs, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; @@ -28,6 +33,33 @@ describe('build-prebid-external metadata', () => { ); }); + it('includes generated User ID metadata in the production external bundle', async () => { + const outputDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'trusted-server-prebid-build-test-') + ); + + try { + await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'pairIdSystem,lockrAIMIdSystem', + '--out', + outputDirectory, + ]); + + const manifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); + expect(bundle).toContain('["pairIdSystem","lockrAIMIdSystem"]'); + } finally { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } + }, 120_000); + it('resolves relative output paths against the current working directory', () => { const parsed = parseArgs(['--adapters', 'rubicon', '--out', 'dist/prebid']); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index e6b5fd354..726f40b49 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -344,6 +344,10 @@ describe('prebid/installPrebidNpm', () => { source: 'sharedid.org', uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], }, + { + source: 'google.com', + uids: [{ id: 'pair_123', atype: 571187 }], + }, ]); const result = spec.buildRequests([ @@ -365,6 +369,10 @@ describe('prebid/installPrebidNpm', () => { source: 'sharedid.org', uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], }, + { + source: 'google.com', + uids: [{ id: 'pair_123', atype: 571187 }], + }, ]); }); @@ -398,7 +406,7 @@ describe('prebid/installPrebidNpm', () => { }, { id: 'uid-bad-atype', - atype: 999, + atype: 2_147_483_648, ext: { keep: true }, }, { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781e..879eb4b91 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -22,6 +22,7 @@ pull_sync_concurrency = 3 # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" +# OpenRTB agent type; vendor-specific values are supported (PAIR uses 571187). # openrtb_atype = 3 # bidstream_enabled = true # api_token = "replace-with-partner-api-token-32-bytes-minimum" From b52b415755fbffd63935a94ad3d14f8165b82440 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 11 Jul 2026 00:32:35 +0530 Subject: [PATCH 008/494] Dump full SSAT prebid responses in ts-debug auction comment The server-side auction stream path only emitted a summary counter (ssp/mediator/winning/time), so an operator seeing winning=0 could not tell whether prebid returned nothing, errored, or bid below the floor. Serialize the full provider_responses (and mediator_response) into the ts-debug HTML comment so the SSAT surfaces the same prebid server response detail available from the /auction endpoint. Bid creative and metadata are attacker/partner-influenced, so neutralize the '-->' and '--!>' comment terminators before embedding to keep the dump inside the comment and out of the live DOM. --- crates/trusted-server-core/src/publisher.rs | 92 ++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5d..c14410121 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -871,8 +871,28 @@ pub(crate) fn prepend_auction_debug_comment( Some(r) => format!("ok({}_bids)", r.bids.len()), None => "none".to_string(), }; + // Full per-provider (and mediator) dump so the operator can see exactly what + // each SSP returned — `status` (nobid vs error vs success), every `bids` + // entry, and `metadata` (which carries PBS `ext.errors` / `ext.debug.httpcalls` + // when prebid `debug=true`) — without needing log access. + // + // `Bid.creative` and provider metadata are attacker/partner-influenced and + // may contain `-->` (or the `--!>` variant), which would terminate the HTML + // comment early and leak the remaining markup into the live DOM. Neutralise + // both terminators before embedding so the dump stays inside the comment. + let neutralise_comment_terminators = + |json: String| -> String { json.replace("-->", "-- >").replace("--!>", "-- !>") }; + let providers_dump = serde_json::to_string_pretty(&result.provider_responses) + .map(neutralise_comment_terminators) + .unwrap_or_else(|e| format!("")); + let mediator_dump = serde_json::to_string_pretty(&result.mediator_response) + .map(neutralise_comment_terminators) + .unwrap_or_else(|e| format!("")); let debug_comment = format!( - "", + "", result.winning_bids.len(), result.total_time_ms, ); @@ -2439,6 +2459,76 @@ mod tests { use super::*; use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::integrations::IntegrationRegistry; + + #[test] + fn auction_debug_comment_dumps_provider_status_and_neutralises_terminators() { + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::AuctionResponse; + + // One provider that returned nothing (the `winning=0` case) and one that + // returned a bid whose creative embeds an HTML-comment terminator. + let no_bid = AuctionResponse::no_bid("prebid", 665); + let mut bid = make_test_bid_with_creative("
evil-->break
"); + bid.slot_id = "ad-header-0".to_string(); + let with_bid = AuctionResponse::success("aps", vec![bid], 42); + + let result = OrchestrationResult { + provider_responses: vec![no_bid, with_bid], + mediator_response: None, + winning_bids: std::collections::HashMap::new(), + total_time_ms: 665, + metadata: std::collections::HashMap::new(), + }; + + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); + prepend_auction_debug_comment("stream", &result, &state); + let comment = state + .lock() + .expect("should lock state") + .clone() + .expect("should have comment"); + + assert!( + comment.contains("\"status\": \"nobid\""), + "should surface the no-bid provider status: {comment}" + ); + assert!( + comment.contains("provider_responses="), + "should dump the provider_responses payload" + ); + // The creative's `-->` must be neutralised so the only comment terminator + // is the trailing one — otherwise embedded markup would leak into the DOM. + assert_eq!( + comment.matches("-->").count(), + 1, + "creative `-->` must be neutralised, leaving only the closing terminator: {comment}" + ); + assert!( + comment.contains("evil-- >break"), + "should retain the creative content with the terminator neutralised" + ); + } + + fn make_test_bid_with_creative(creative: &str) -> Bid { + Bid { + slot_id: "slot".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: Some(creative.to_string()), + adomain: None, + bidder: "seat".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + } + } + use crate::platform::test_support::{ build_services_with_http_client, noop_services, StubHttpClient, }; From 48db8bf6a56dd31fd2a2b7334354b12f882b8fd2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 11 Jul 2026 00:51:41 +0530 Subject: [PATCH 009/494] Surface prebid HTTP error status and body in auction dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Prebid Server returns a non-2xx status, the parser returned a bare AuctionResponse::error with empty metadata — indistinguishable in the ts-debug dump from a transport, parse, or timeout failure, all of which tag error_type. An operator seeing status=error with metadata={} had no way to know the upstream HTTP code without log access. Attach error_type=http_status, the status code, and a 512-byte body snippet to the error response metadata so the auction dump shows exactly why prebid errored (e.g. a 4xx from a PBS rejecting the request). --- .../src/integrations/prebid.rs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a85..c6fccc2c3 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2124,14 +2124,23 @@ impl AuctionProvider for PrebidAuctionProvider { if !status.is_success() { log::warn!("Prebid returned non-success status: {}", status,); + let body_preview = String::from_utf8_lossy(&body_bytes); if log::log_enabled!(log::Level::Trace) { - let body_preview = String::from_utf8_lossy(&body_bytes); log::trace!( "Prebid error response body: {}", &body_preview[..body_preview.floor_char_boundary(1000)] ); } - return Ok(AuctionResponse::error("prebid", response_time_ms)); + // Surface the HTTP status and a body snippet on the response metadata + // so the ts-debug auction dump shows *why* prebid errored (e.g. a 4xx + // from a PBS that rejects the unsigned server-side request) without + // needing log access. A bare `AuctionResponse::error` yields empty + // metadata, which is indistinguishable from other failures in the dump. + let body_snippet = body_preview[..body_preview.floor_char_boundary(512)].to_string(); + return Ok(AuctionResponse::error("prebid", response_time_ms) + .with_metadata("error_type", serde_json::json!("http_status")) + .with_metadata("status", serde_json::json!(status.as_u16())) + .with_metadata("body", serde_json::json!(body_snippet))); } let response_json: Json = @@ -2341,6 +2350,44 @@ mod tests { ); } + #[test] + fn parse_response_attaches_status_and_body_metadata_on_http_error() { + use crate::auction::types::BidStatus; + + let provider = PrebidAuctionProvider::new(base_config()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(403) + .body(EdgeBody::from(br#"{"error":"missing signature"}"#.to_vec())) + .expect("should build test response"), + ); + + let result = futures::executor::block_on(provider.parse_response(response, 643)) + .expect("should return Ok(error response) for non-success status"); + + assert_eq!( + result.status, + BidStatus::Error, + "non-success HTTP status should map to an error response" + ); + assert_eq!( + result.metadata["error_type"], + json!("http_status"), + "should tag the error path so the auction dump is distinguishable" + ); + assert_eq!( + result.metadata["status"], + json!(403), + "should surface the upstream HTTP status code" + ); + assert!( + result.metadata["body"] + .as_str() + .is_some_and(|body| body.contains("missing signature")), + "should include the response body snippet" + ); + } + fn test_sri(algorithm: &str, digest: &[u8]) -> String { format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) } From d5871da84ec21af5c8118c402b5e2243b0731c0f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Jul 2026 13:12:30 +0530 Subject: [PATCH 010/494] Stop leaking prebid error body into the public auction response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review of the SSAT debug-dump change: - The PBS non-2xx response body was attached to AuctionResponse.metadata, which ProviderSummary clones verbatim into ext.orchestrator.provider_details on the public /auction response — violating the documented invariant in auction/orchestrator.rs. Drop the body from metadata, keep only the numeric status, and log the snippet server-side at warn. - Register ERROR_TYPE_HTTP_STATUS and match it in provider_status so PBS HTTP errors get their own telemetry bucket instead of the transport_error fallback. - Bound the ts-debug dump: compact serialization capped at 256 KiB, and skip the mediator_response line when no mediator ran. - Correct the auction_html_comment and prepend_auction_debug_comment docs to state the comment now embeds raw SSP creative markup (never enable in prod). - Keep the targeted two-replace terminator neutralisation: a single replace("--", ...) re-forms -->/--!> at odd dash-run junctions and is not equivalent. Add a table-driven test over the comment-terminator vectors. - Hoist test-local imports to module scope per CLAUDE.md. --- .../src/auction/orchestrator.rs | 5 + .../src/auction/telemetry.rs | 1 + .../src/integrations/prebid.rs | 58 +++--- crates/trusted-server-core/src/publisher.rs | 189 +++++++++++------- crates/trusted-server-core/src/settings.rs | 8 +- 5 files changed, 165 insertions(+), 96 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 145059d9e..45d90e761 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -96,6 +96,11 @@ const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; const ERROR_TYPE_TRANSPORT: &str = "transport"; const ERROR_TYPE_TIMEOUT: &str = "timeout"; +/// A non-2xx HTTP status from an upstream SSP (e.g. a PBS 4xx/5xx). Distinct +/// from [`ERROR_TYPE_TRANSPORT`] (a connection-level failure) so telemetry can +/// bucket it separately. `pub(crate)` so producers such as the prebid provider +/// tag errors with the exact value the telemetry layer recognises. +pub(crate) const ERROR_TYPE_HTTP_STATUS: &str = "http_status"; // SECURITY: the returned string is included verbatim (truncated to // PROVIDER_ERROR_MESSAGE_CHARS) in the public /auction response via diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 4819cef6c..ac92a98f1 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -800,6 +800,7 @@ fn provider_status(response: &AuctionResponse) -> &'static str { Some("parse_response") => "parse_error", Some("transport") => "transport_error", Some("timeout") => "timeout", + Some("http_status") => "http_status_error", _ => "transport_error", }, BidStatus::Pending => "timeout", diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c6fccc2c3..22c972f5c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2123,24 +2123,25 @@ impl AuctionProvider for PrebidAuctionProvider { })?; if !status.is_success() { - log::warn!("Prebid returned non-success status: {}", status,); let body_preview = String::from_utf8_lossy(&body_bytes); - if log::log_enabled!(log::Level::Trace) { - log::trace!( - "Prebid error response body: {}", - &body_preview[..body_preview.floor_char_boundary(1000)] - ); - } - // Surface the HTTP status and a body snippet on the response metadata - // so the ts-debug auction dump shows *why* prebid errored (e.g. a 4xx - // from a PBS that rejects the unsigned server-side request) without - // needing log access. A bare `AuctionResponse::error` yields empty - // metadata, which is indistinguishable from other failures in the dump. - let body_snippet = body_preview[..body_preview.floor_char_boundary(512)].to_string(); + // SECURITY: the PBS response body is upstream-controlled and may leak + // internal detail (hostnames, stack traces, auth hints). Per the + // invariant documented in `auction/orchestrator.rs`, it MUST NOT reach + // the public `/auction` response, which happens if it lands in + // `AuctionResponse.metadata` (cloned verbatim into + // `ext.orchestrator.provider_details[].metadata`). Log the snippet + // server-side and surface only the numeric HTTP status — enough for an + // operator to tell an error from a no-bid without publishing the body. + log::warn!( + "Prebid returned non-success status {status}: {}", + &body_preview[..body_preview.floor_char_boundary(512)] + ); return Ok(AuctionResponse::error("prebid", response_time_ms) - .with_metadata("error_type", serde_json::json!("http_status")) - .with_metadata("status", serde_json::json!(status.as_u16())) - .with_metadata("body", serde_json::json!(body_snippet))); + .with_metadata( + "error_type", + serde_json::json!(crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS), + ) + .with_metadata("status", serde_json::json!(status.as_u16()))); } let response_json: Json = @@ -2242,7 +2243,8 @@ mod tests { use super::*; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, + UserInfo, }; use crate::consent::{ConsentContext, ConsentSource}; @@ -2351,14 +2353,14 @@ mod tests { } #[test] - fn parse_response_attaches_status_and_body_metadata_on_http_error() { - use crate::auction::types::BidStatus; - + fn parse_response_attaches_status_metadata_without_leaking_body_on_http_error() { let provider = PrebidAuctionProvider::new(base_config()); let response = PlatformResponse::new( edgezero_core::http::response_builder() .status(403) - .body(EdgeBody::from(br#"{"error":"missing signature"}"#.to_vec())) + .body(EdgeBody::from( + br#"{"error":"upstream-secret-detail"}"#.to_vec(), + )) .expect("should build test response"), ); @@ -2373,18 +2375,24 @@ mod tests { assert_eq!( result.metadata["error_type"], json!("http_status"), - "should tag the error path so the auction dump is distinguishable" + "should tag the error path so telemetry buckets it as an http status error" ); assert_eq!( result.metadata["status"], json!(403), "should surface the upstream HTTP status code" ); + // SECURITY: the upstream response body must never reach the public + // /auction response via AuctionResponse.metadata. + assert!( + !result.metadata.contains_key("body"), + "upstream response body must not be surfaced on the response metadata" + ); assert!( - result.metadata["body"] + !result.metadata.values().any(|v| v .as_str() - .is_some_and(|body| body.contains("missing signature")), - "should include the response body snippet" + .is_some_and(|s| s.contains("upstream-secret-detail"))), + "no metadata value may contain the upstream body" ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c14410121..56d3c77f6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -855,12 +855,23 @@ pub(crate) fn write_bids_to_state( *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } -/// Prepend an HTML comment summarising the auction result onto the shared -/// `ad_bids_state` so it lands directly before the injected bids `` breakout and `U+2028/2029`. Requirement: a regression test proving a +hostile `adm` containing `` (and `U+2028/2029`) cannot break out of the +injected `` / + `U+2028/2029` `adm` is escaped so `build_bids_script` output stays inside the + `" + ) +} +``` + +- [ ] **Step 4: Update the caller** at `~853/854` to pass `settings.debug.inject_adm_for_testing`; update the empty-bids helper at `~2033` and any test expectations that pin the old script string. + +- [ ] **Step 5: Run — expect PASS.** `cargo test-fastly bids_script_emits_inject_adm_for_testing_flag` + +- [ ] **Step 6: Commit** — `git commit -m "Emit injectAdmForTesting flag on window.tsjs with bids"` + +--- + +## Task 3: Escaping regression — hostile `adm` cannot break out of `` + `U+2028` adm is neutralized in the emitted script. + +```rust +#[test] +fn build_bids_script_escapes_hostile_adm() { + let mut winning = std::collections::HashMap::new(); + let mut bid = /* Bid, price Some(1.0), creative Some("\u{2028}") */; + winning.insert("s".to_string(), bid); + let map = build_bid_map(&winning, PriceGranularity::Dense, true, false); + let script = build_bids_script(&map, false); + // Raw must not survive; U+2028 must be unicode-escaped. + assert!(!script.contains("" - ) -} -``` - -- [ ] **Step 4: Update `build_bids_script` callers:** - - `write_bids_to_state` (`~854`): pass the `inject_adm_for_testing` param threaded in Task 1. - - `build_empty_bids_script` (`~2032`) has **no settings access** — pass `false` (no bids ⇒ no `adm` ⇒ flag inert). Documented tradeoff: an empty *initial* nav on a testing build emits `injectAdmForTesting=false`, so a later SPA-loaded `adm` won't fire the test bypass; acceptable (production is always `false`). - - Fix any test that pins the old `bids` `\u{2028}") */; - winning.insert("s".to_string(), bid); - let map = build_bid_map(&winning, PriceGranularity::Dense, true, false); - let script = build_bids_script(&map, false); - // Raw must not survive; U+2028 must be unicode-escaped. - assert!(!script.contains("\u{2028}"), + ); + let map = build_bid_map(&winning, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` breakout and `U+2028/2029`. Requirement: a regression test proving a -hostile `adm` containing `` (and `U+2028/2029`) cannot break out of the -injected `` breakout and `U+2028/2029`. **This is the guarantee trusted-server +directly provides**, pinned by a hostile-`adm` regression test. + +Frame isolation of the rendered creative is **not** guaranteed by TS on the +bridge path: `injectAdmIntoSlot` sets `sandbox=ADM_IFRAME_SANDBOX`, but the +bridge renderer hands `adm` to the PUC-provided `mkFrame`, which TS neither sets +nor verifies a sandbox on. Bridge isolation therefore depends on the Prebid +Universal Creative implementation, not on TS. ## Components changed | Unit | Change | | --- | --- | -| `build_bid_map` (Rust) | Split `include_adm` → `render_adm` (always) + `debug_bid` (testing). Always insert `adm` for winners. | -| `build_bid_map` callers | Pass `render_adm = true`; `debug_bid = inject_adm_for_testing`. | -| tsjs config injection (Rust→JS) | Surface `injectAdmForTesting` flag. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on the injected `injectAdmForTesting` flag, not bare `bid.adm`. | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | + +No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. ## Data flow (after) @@ -110,40 +122,42 @@ SSAT auction → winner (bid.creative held) → build_bid_map inserts adm → build_bids_script (html_escape_for_script) → window.tsjs.bids → hb_pb targeting → GAM competes ├ GAM picks TS line item → PUC "Prebid Request" - │ → bridge replies with local adm → RENDER (no round trip) + │ → bridge replies with local adm → RENDER (no round trip) + beacons │ → (adm absent) → PBS Cache fetch → RENDER (fallback) └ GAM has higher demand → GAM serves its own creative ``` -## Testing - -- **Rust**: `build_bid_map` includes `adm` for winners on the production path; - `debug_bid` present only under the testing flag; a hostile `` / - `U+2028/2029` `adm` is escaped so `build_bids_script` output stays inside the - `` / `U+2028/2029` + `adm` is escaped so `build_bids_script` output stays inside the `\u{2028}"), - ); + let mut bid = make_bid("s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill"); + // Both line/paragraph separators — the spec promises escaping for each. + bid.creative = Some("\u{2028}\u{2029}".to_string()); + winning.insert("s".to_string(), bid); let map = build_bid_map(&winning, PriceGranularity::Dense, false); let script = build_bids_script(&map); assert!( @@ -115,8 +119,8 @@ fn build_bids_script_escapes_hostile_adm() { "should not let a hostile adm break out of the script context" ); assert!( - !script.contains('\u{2028}'), - "should unicode-escape U+2028 in the adm" + !script.contains('\u{2028}') && !script.contains('\u{2029}'), + "should unicode-escape both U+2028 and U+2029 in the adm" ); } ``` @@ -135,12 +139,15 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:~599` - Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- [ ] **Step 1: Write failing vitest** — bypass does NOT fire in production (no `debug_bid`), even with `bid.adm`. +- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its *effect* on the DOM: ```ts -// window.tsjs.bids = { 'ad-header-0': { adm: '
x
' } } // no debug_bid -// simulate slotRenderEnded for ad-header-0 -// spy on injectAdmIntoSlot → assert NOT called (the bridge handles render) +// Setup: +// bids['ad-header-0'] = { adm: '' } // NO debug_bid +// place an existing GAM iframe (src="about:blank") in the slot div +// capture the slotRenderEnded listener, fire it for 'ad-header-0' +// Assert (production): the GAM iframe src stays 'about:blank' +// — the bypass did not fire; the render bridge handles it. ``` - [ ] **Step 2: Run — expect FAIL.** `cd crates/trusted-server-js/lib && npx vitest run ad_init` @@ -156,7 +163,7 @@ if (bid.adm && bid.debug_bid) { } ``` -- [ ] **Step 4: Add companion test** — with `bid.debug_bid` present, `injectAdmIntoSlot` IS called. +- [ ] **Step 4: Add companion test (testing mode)** — same setup but with `bid.debug_bid` present. Fire `slotRenderEnded` → assert the slot iframe's `src` **changes to** the creative URL (`https://cdn.example/creative.html`), proving `injectAdmIntoSlot` ran. - [ ] **Step 5: Run — expect PASS.** @@ -194,8 +201,9 @@ concurrency + beacon dedup. Do **not** duplicate them. cargo clippy-spin-wasm ``` - [ ] **Step 4:** `cd crates/trusted-server-js/lib && npx vitest run && npm run format && node build-all.mjs` -- [ ] **Step 5:** Manual: with `[debug].auction_html_comment` off, load a nav page; confirm the winning creative renders **without** a request to `hb_cache_host` (Network tab) and GAM still received `hb_pb`. -- [ ] **Step 6: Commit** any format fixes. +- [ ] **Step 5:** Docs format (these spec/plan docs changed): `cd docs && npm run format` +- [ ] **Step 6:** Manual: with `[debug].auction_html_comment` off, load a nav page; confirm the winning creative renders **without** a request to `hb_cache_host` (Network tab) and GAM still received `hb_pb`. +- [ ] **Step 7: Commit** any format fixes. --- From 6649875643b5c70b2cf218fbf477a9fa9eaae47a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Jul 2026 22:53:35 +0530 Subject: [PATCH 016/494] Always include adm in bid map; gate only debug_bid blob build_bid_map now always inserts the winning creative as adm so the pbRender bridge can render it locally (no PBS Cache round trip); the verbose debug_bid blob and the GAM-bypass gate stay behind inject_adm_for_testing. Rename the param include_adm -> include_debug_bid and thread it through write_bids_to_state. Reconcile the by-default test to the new behavior, drop the now-redundant debug-only-adm test, and pin script-context escaping for a hostile adm ( + U+2028/U+2029). --- crates/trusted-server-core/src/publisher.rs | 66 ++++++++++++--------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5d..f32d32891 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -843,14 +843,14 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, - inject_adm: bool, + include_debug_bid: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); + let bid_map = build_bid_map(winning_bids, price_granularity, include_debug_bid); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1933,7 +1933,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, - include_adm: bool, + include_debug_bid: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1978,12 +1978,16 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } - // Include raw creative markup only for explicit debug injection. - // The pbRender bridge can use it while PBS Cache is unavailable. - if include_adm { - if let Some(ref adm) = bid.creative { - obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); - } + // Always include the winning creative so the pbRender bridge can + // render it locally when GAM serves the Prebid Universal Creative + // — no PBS Cache round trip. The `hb_cache_*` coordinates above + // remain as the fallback for an absent `adm`. + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + // Verbose per-bid debug blob only under the testing flag; also + // doubles as the client-side gate for the direct GAM-replace path. + if include_debug_bid { obj.insert( "debug_bid".to_string(), serde_json::json!({ @@ -4071,7 +4075,7 @@ mod tests { } #[test] - fn client_bid_map_omits_adm_by_default() { + fn client_bid_map_includes_adm_and_omits_debug_bid_by_default() { let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -4084,6 +4088,9 @@ mod tests { bid.creative = Some("
Creative
".to_string()); winning_bids.insert("atf_sidebar_ad".to_string(), bid); + // Production path (include_debug_bid = false): the creative is always + // included so the bridge can render it locally, but the verbose + // debug_bid blob is not. let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let obj = map .get("atf_sidebar_ad") @@ -4091,41 +4098,42 @@ mod tests { .as_object() .expect("should be object"); - assert!( - obj.get("adm").is_none(), - "should omit adm when debug injection is disabled" + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include creative markup for local rendering by default" ); assert!( obj.get("debug_bid").is_none(), - "should omit debug bid when debug injection is disabled" + "should omit the debug_bid blob when debug injection is disabled" ); } #[test] - fn client_bid_map_includes_adm_when_debug_injection_enabled() { + fn build_bids_script_escapes_hostile_adm() { let mut winning_bids = HashMap::new(); let mut bid = make_bid( - "atf_sidebar_ad", + "s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill", ); - bid.creative = Some("
Creative
".to_string()); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); + // A hostile creative that tries to break out of the \u{2028}\u{2029}".to_string()); + winning_bids.insert("s".to_string(), bid); - assert_eq!( - obj.get("adm").and_then(|v| v.as_str()), - Some("
Creative
"), - "should include adm when debug injection is enabled" + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` + `U+2028` adm is neutralized in the emitted script. @@ -136,10 +138,11 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` ## Task 3: Gate the GAM-bypass (`injectAdmIntoSlot`) on `bid.debug_bid` **Files:** + - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:~599` - Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its *effect* on the DOM: +- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its _effect_ on the DOM: ```ts // Setup: @@ -159,7 +162,7 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` // when inject_adm_for_testing is on, so it doubles as the per-bid gate — no // global flag needed, and it is correct across SPA auction responses. if (bid.adm && bid.debug_bid) { - injectAdmIntoSlot(divId, bid.adm); + injectAdmIntoSlot(divId, bid.adm) } ``` @@ -174,6 +177,7 @@ if (bid.adm && bid.debug_bid) { ## Task 4: Reconcile existing bridge tests (no duplicates) **Files:** + - Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` `ad_init.test.ts` already covers: PBS Cache fetch when `adm` absent; local `adm` @@ -208,7 +212,8 @@ concurrency + beacon dedup. Do **not** duplicate them. --- ## Notes -- Do NOT remove `hb_cache_host`/`hb_cache_path` — they are the fallback for an **absent** `adm`. Render failure *after* `adm` is supplied is not detectable and does not fall back (spec Risks). + +- Do NOT remove `hb_cache_host`/`hb_cache_path` — they are the fallback for an **absent** `adm`. Render failure _after_ `adm` is supplied is not detectable and does not fall back (spec Risks). - Do NOT ship the `debug_bid` blob in production (Task 1 keeps it behind the flag). - No global `window.tsjs` flag, no `TsjsApi` change — the bypass gate is the per-bid `debug_bid`. - Page-weight cost (inline creatives, uncacheable response) accepted per spec; size-capping out of scope. diff --git a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md index 8c826d632..8dee3f452 100644 --- a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md +++ b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md @@ -14,7 +14,7 @@ time from PBS Cache: https://?uuid= ``` -This is an extra network round trip *after* the GAM call, even though +This is an extra network round trip _after_ the GAM call, even though trusted-server already holds the winning creative markup (`bid.creative`) from the server-side auction it just ran. The client-side `/auction` flow never does this — Prebid.js renders the winner from the copy it already has in the browser. @@ -27,7 +27,7 @@ while keeping GAM in the loop (the header bid still competes against GAM's own demand via `hb_pb`). Non-goal: bypassing GAM. SSAT winners must still compete in GAM; we only remove -the round trip that happens *after* GAM has already picked the TS line item. +the round trip that happens _after_ GAM has already picked the TS line item. ## Current flow (verified in code) @@ -47,7 +47,7 @@ the round trip that happens *after* GAM has already picked the TS line item. - **fetches from PBS Cache** using `hb_cache_host`/`hb_cache_path` (the round trip we want to remove). 5. A separate consumer, `injectAdmIntoSlot` ([gpt/index.ts:599]), fires on - `if (bid.adm)` and **replaces the GAM creative directly** — a GAM *bypass*. + `if (bid.adm)` and **replaces the GAM creative directly** — a GAM _bypass_. Its "testing only" status is a comment, not an actual gate. ## Design @@ -55,6 +55,7 @@ the round trip that happens *after* GAM has already picked the TS line item. ### 1. Always include the render `adm`; keep `debug_bid` gated `build_bid_map`: + - **Always** insert `adm` (from `bid.creative`) for a winner when present — there is no runtime reason to withhold it, so it is not parameterized. - Insert the verbose `debug_bid` blob **only** when the testing flag is set. The @@ -62,7 +63,7 @@ the round trip that happens *after* GAM has already picked the TS line item. `hb_cache_host`/`hb_cache_path` remain inserted unconditionally. -### 2. Bridge renders local `adm`; cache is the fallback for an *absent* `adm` +### 2. Bridge renders local `adm`; cache is the fallback for an _absent_ `adm` `installTsRenderBridge` already prefers `matchedBid.adm` and falls back to PBS Cache. Once `adm` is present in production, the local render becomes the default @@ -70,7 +71,7 @@ and the round trip disappears. **Fallback scope (corrected):** the bridge posts the markup to the PUC and returns; it receives **no render-success signal**. So the PBS Cache fallback -fires only when `adm` is **absent or empty** — *not* when `adm` is present but +fires only when `adm` is **absent or empty** — _not_ when `adm` is present but fails to render. Render failures after `adm` is supplied are not currently detectable and do not trigger fallback. @@ -84,7 +85,7 @@ the bypass on the per-bid `debug_bid` field, which is already present **iff** ```ts if (bid.adm && bid.debug_bid) { - injectAdmIntoSlot(divId, bid.adm); + injectAdmIntoSlot(divId, bid.adm) } ``` @@ -106,12 +107,12 @@ Universal Creative implementation, not on TS. ## Components changed -| Unit | Change | -| --- | --- | -| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | -| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | -| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | +| Unit | Change | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. @@ -129,7 +130,7 @@ SSAT auction → winner (bid.creative held) → build_bid_map inserts adm ## Precondition -This changes only the render bridge's *data source* — local `adm` vs a PBS Cache +This changes only the render bridge's _data source_ — local `adm` vs a PBS Cache fetch — **when GAM's Prebid line item already serves the PUC**. It does not change GAM competition, nor whether the PUC fires. A publisher without Prebid line items in GAM sees no behavioral change. From c8ae53f78014458241753f53db630420ff119132 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 15:49:42 +0530 Subject: [PATCH 019/494] Harden Fastly publisher streaming against review findings Resolve five correctness and resource-safety findings from the PR #867 review of the end-to-end Fastly publisher streaming path. - Drive the deflate decoder to StreamEnd at finalization so a valid stream that exactly fills the internal output buffer is no longer rejected as truncated; the inflater is also drained after all input is consumed within a chunk. - Decode concatenated (multi-member) gzip bodies via MultiGzDecoder on both the streaming decoder and the buffered read pipeline so adapters agree. - Enforce the decoded-body cap during decompression through a bounded sink shared by the gzip and brotli codecs, so a compression bomb errors before its expanded bytes are buffered instead of after a full chunk expands; the deflate codec charges each produced block as it is emitted. - Drop the body of bodiless responses (HEAD, 204, 205, 304) in both the streaming and buffered finalizer Buffered arms, and add RESET_CONTENT to response_carries_body, so a buffered-unmodified stream body is never streamed to the client for a response that must be bodiless. - Keep the dispatched-auction guard armed across the collection await and disarm it only once collection reaches a terminal result, so a body dropped while collection is pending still logs the discarded SSP work. Add regression tests for the deflate output-buffer boundary, multi-member gzip, bodiless buffered stream bodies, and the auction guard sentinel. --- crates/trusted-server-core/src/publisher.rs | 166 +++++++- .../src/streaming_processor.rs | 391 +++++++++++++++--- 2 files changed, 484 insertions(+), 73 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d7e38cf9..3f9160201 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -612,23 +612,40 @@ fn passthrough_finish_segments( /// error paths that can still await (see [`abandon_hold_auction`]). struct DispatchedAuctionGuard { dispatched: Option, + /// Stays `true` from dispatch until collection (or telemetry-emitting + /// abandonment) reaches a terminal result. [`Self::take`] removes the + /// dispatched auction to hand it to the async collector but deliberately + /// leaves the guard armed, so a drop *while collection is still pending* — + /// a client disconnect at the collection await point — still logs the + /// loss. [`Self::disarm`] clears it only once collection has completed. + armed: bool, } impl DispatchedAuctionGuard { fn new(dispatched: DispatchedAuction) -> Self { Self { dispatched: Some(dispatched), + armed: true, } } + /// Remove the dispatched auction to begin collection. The guard stays armed + /// until [`Self::disarm`] is called, so a drop before collection reaches a + /// terminal result is still reported. fn take(&mut self) -> Option { self.dispatched.take() } + + /// Disarm the drop warning once collection (or telemetry-emitting + /// abandonment) has reached a terminal result. + fn disarm(&mut self) { + self.armed = false; + } } impl Drop for DispatchedAuctionGuard { fn drop(&mut self) { - if self.dispatched.is_some() { + if self.armed { log::warn!( "Dispatched server-side auction dropped without collection; SSP bid responses discarded (publisher body stream aborted or never polled)" ); @@ -668,6 +685,10 @@ async fn abandon_hold_auction( reason, ) .await; + // Abandonment with telemetry is a terminal result, so the drop warning + // is no longer warranted. (A drop *during* the emit above still fires + // it, since the guard stays armed until here.) + state.dispatched.disarm(); } } @@ -721,6 +742,9 @@ async fn hold_step_decoded_chunk( collect_refs.settings, ) .await; + // Collection reached a terminal result; disarm only now so a drop + // while the collect await above was still pending is reported. + state.dispatched.disarm(); let held = state .hold @@ -835,6 +859,9 @@ async fn hold_finish_segments( collect_refs.settings, ) .await; + // Collection reached a terminal result; disarm only now so a drop while + // the collect await above was still pending is reported. + state.dispatched.disarm(); let held = hold.finish(); if let Some(encoded) = process_and_encode_chunk( @@ -1046,7 +1073,17 @@ pub async fn buffer_publisher_response_async( services: &RuntimeServices, ) -> Result, Report> { match publisher_response { - PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Buffered(mut response) => { + // A buffered-unmodified response can carry an origin body (a stream + // on streaming-capable adapters). A bodiless response (HEAD, 204, + // 205, 304) must stay bodiless, so drop the body while preserving + // metadata such as `Content-Length`, matching the streaming + // finalizer. + if !response_carries_body(method, response.status()) { + *response.body_mut() = EdgeBody::empty(); + } + Ok(response) + } PublisherResponse::Stream { mut response, body, @@ -1113,7 +1150,18 @@ pub async fn publisher_response_into_streaming_response( services: RuntimeServices, ) -> Result, Report> { match publisher_response { - PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Buffered(mut response) => { + // Fastly requests the origin body as a stream before the response is + // classified, so a buffered-unmodified response can still hold an + // `EdgeBody::Stream`. A bodiless response (HEAD, 204, 205, 304) must + // stay bodiless — `send_edgezero_response` streams any + // `EdgeBody::Stream` to the client — so drop the body while + // preserving metadata such as `Content-Length`. + if !response_carries_body(method, response.status()) { + *response.body_mut() = EdgeBody::empty(); + } + Ok(response) + } PublisherResponse::PassThrough { mut response, body } => { if response_carries_body(method, response.status()) { *response.body_mut() = body; @@ -1196,6 +1244,10 @@ pub async fn publisher_response_into_streaming_response( &settings, ) .await; + // Collection reached a terminal result; disarm only now + // so a drop while the collect await above was still + // pending is reported. + guard.disarm(); } } @@ -1268,12 +1320,15 @@ pub async fn publisher_response_into_streaming_response( /// Returns `true` when a buffered publisher response should carry a body and a /// recomputed `Content-Length`. /// -/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting -/// their `Content-Length` to the (empty) buffered length would mislead clients -/// and caches, so the origin metadata is preserved instead. +/// `HEAD` responses and bodiless statuses (204, 205, 304) carry no body; +/// rewriting their `Content-Length` to the (empty) buffered length — or +/// streaming an origin body for them at all — would mislead clients and caches +/// and violate HTTP framing, so the origin metadata is preserved and the body +/// is dropped instead. fn response_carries_body(method: &Method, status: StatusCode) -> bool { *method != Method::HEAD && status != StatusCode::NO_CONTENT + && status != StatusCode::RESET_CONTENT && status != StatusCode::NOT_MODIFIED } @@ -3755,12 +3810,44 @@ mod tests { !super::response_carries_body(&Method::GET, StatusCode::NO_CONTENT), "204 responses must not get a recomputed Content-Length" ); + assert!( + !super::response_carries_body(&Method::GET, StatusCode::RESET_CONTENT), + "205 responses must not get a recomputed Content-Length" + ); assert!( !super::response_carries_body(&Method::GET, StatusCode::NOT_MODIFIED), "304 responses must not get a recomputed Content-Length" ); } + #[test] + fn dispatched_auction_guard_stays_armed_until_collection_completes() { + // `take()` hands the dispatched auction to the async collector, but the + // guard must stay armed across the collection await so a drop while + // collection is still pending (a client disconnect at the await point) + // still logs the loss. Only `disarm()` — called once collection reaches + // a terminal result — clears the warning. + let mut guard = DispatchedAuctionGuard::new(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )); + assert!(guard.armed, "a freshly dispatched guard should be armed"); + + let _dispatched = guard + .take() + .expect("guard should yield the dispatched auction for collection"); + assert!( + guard.armed, + "guard must stay armed across the collection await so a drop mid-collection is reported" + ); + + guard.disarm(); + assert!( + !guard.armed, + "guard must disarm once collection reaches a terminal result" + ); + } + fn response_body_string(response: http::Response) -> String { String::from_utf8( response @@ -5354,6 +5441,73 @@ mod tests { ); } + #[test] + fn publisher_response_streaming_finalize_drops_bodiless_buffered_stream_body() { + // Fastly requests the origin body as a stream before classification, so + // a buffered-unmodified response can hold an `EdgeBody::Stream`. The + // adapter streams any `EdgeBody::Stream` to the client, so bodiless + // responses must be normalized to carry no body while keeping metadata. + let settings = Arc::new(create_test_settings()); + let registry = Arc::new( + IntegrationRegistry::new(&settings).expect("should create integration registry"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + + let cases = [ + (Method::HEAD, StatusCode::OK), + (Method::GET, StatusCode::NO_CONTENT), + (Method::GET, StatusCode::RESET_CONTENT), + (Method::GET, StatusCode::NOT_MODIFIED), + ]; + + for (method, status) in cases { + let response = Response::builder() + .status(status) + .header(header::CONTENT_LENGTH, "42") + .body(EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"origin body bytes that must not reach the client"), + ]))) + .expect("should build response"); + let publisher_response = PublisherResponse::Buffered(response); + + let response = futures::executor::block_on(publisher_response_into_streaming_response( + publisher_response, + &method, + Arc::clone(&settings), + registry.as_ref(), + Arc::clone(&orchestrator), + noop_services(), + )) + .expect("should finalize buffered response"); + + assert!( + !matches!(response.body(), EdgeBody::Stream(_)), + "bodiless {method} {status} must not carry a streaming body" + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()), + Some("42"), + "bodiless {method} {status} must preserve the origin Content-Length" + ); + + let drained = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("body should drain") + .to_vec(); + assert!( + drained.is_empty(), + "bodiless {method} {status} must deliver zero body bytes, got {} bytes", + drained.len() + ); + } + } + #[test] fn publisher_response_streaming_finalize_processes_gzip_stream() { let compressed = diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index 963c69daa..e7e9a92bb 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -19,7 +19,7 @@ //! streaming interface. See `crate::platform` module doc for the //! authoritative note. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::rc::Rc; @@ -27,7 +27,7 @@ use brotli::enc::writer::CompressorWriter; use brotli::enc::BrotliEncoderParams; use brotli::Decompressor; use error_stack::{Report, ResultExt as _}; -use flate2::read::{GzDecoder, ZlibDecoder}; +use flate2::read::{MultiGzDecoder, ZlibDecoder}; use flate2::write::{GzEncoder, ZlibEncoder}; use crate::error::TrustedServerError; @@ -144,7 +144,10 @@ impl StreamingPipeline

{ ) { (Compression::None, Compression::None) => self.process_chunks(input, output), (Compression::Gzip, Compression::Gzip) => { - let decoder = GzDecoder::new(input); + // Multi-member decoder: RFC 1952 permits concatenated gzip + // members, so a single-member reader would stop after the first. + // Matches the streaming `BodyStreamDecoder` gzip codec. + let decoder = MultiGzDecoder::new(input); let mut encoder = GzEncoder::new(output, flate2::Compression::default()); self.process_chunks(decoder, &mut encoder)?; encoder.finish().change_context(TrustedServerError::Proxy { @@ -153,7 +156,7 @@ impl StreamingPipeline

{ Ok(()) } (Compression::Gzip, Compression::None) => { - self.process_chunks(GzDecoder::new(input), output) + self.process_chunks(MultiGzDecoder::new(input), output) } (Compression::Deflate, Compression::Deflate) => { let decoder = ZlibDecoder::new(input); @@ -360,27 +363,106 @@ pub(crate) const STREAM_CHUNK_SIZE: usize = 8192; /// out of the internal buffer after every push. Write-based decoders are /// used because the async publisher path cannot wrap a blocking `Read`. /// -/// Decoded output is capped cumulatively: the chunk source only bounds raw -/// (still compressed) bytes, and a decompression bomb can expand ~1000x past -/// that, so the decoder enforces its own ceiling on the total bytes it emits. +/// Decoded output is capped cumulatively and the cap is enforced *during* +/// decompression, not after: the chunk source only bounds raw (still +/// compressed) bytes, and a decompression bomb can expand ~1000x past that, so +/// a small compressed chunk must not be allowed to fully expand before the +/// ceiling is checked. The gzip and brotli codecs decode into a +/// [`BoundedDecodeSink`] that errors the moment a write would exceed the limit; +/// the deflate codec charges each produced output block as it is emitted. /// /// Every codec validates end-of-stream at [`Self::finish`] so a truncated /// origin body errors instead of silently truncating the page: gzip via its -/// trailer checksum, brotli via `close()`, and deflate via an explicit -/// [`flate2::Status::StreamEnd`] check (`write::ZlibDecoder` accepts -/// truncated input silently, so the deflate arm drives [`flate2::Decompress`] -/// directly). +/// trailer checksum, brotli via `close()`, and deflate by driving +/// [`flate2::Decompress`] to its [`flate2::Status::StreamEnd`] marker (the +/// `write`-based zlib decoder accepts truncated input silently, so the deflate +/// arm drives [`flate2::Decompress`] directly). Concatenated gzip members +/// (RFC 1952) are decoded via [`flate2::write::MultiGzDecoder`]. pub(crate) struct BodyStreamDecoder { codec: BodyStreamDecoderCodec, - decoded_bytes: usize, + /// Cumulative decoded byte count, shared with the codec sinks so the cap is + /// enforced from inside the decompressor writes rather than after them. + decoded_bytes: Rc>, max_decoded_bytes: usize, } enum BodyStreamDecoderCodec { None, - Gzip(flate2::write::GzDecoder>), + Gzip(flate2::write::MultiGzDecoder), Deflate(DeflateStreamDecoder), - Brotli(Box>>), + Brotli(Box>), +} + +/// A [`Write`] sink that buffers decoded bytes while enforcing a shared +/// cumulative decode budget. +/// +/// The gzip and brotli decoders write their decompressed output here as they +/// process input. Rejecting the write as soon as it would push the cumulative +/// decoded total past `max_decoded_bytes` makes the cap a hard ceiling on +/// Wasm-heap growth: a decompression bomb errors before its expanded bytes are +/// buffered, rather than after a full chunk has already expanded. +struct BoundedDecodeSink { + buffer: Vec, + decoded_bytes: Rc>, + max_decoded_bytes: usize, +} + +impl BoundedDecodeSink { + fn new(decoded_bytes: Rc>, max_decoded_bytes: usize) -> Self { + Self { + buffer: Vec::new(), + decoded_bytes, + max_decoded_bytes, + } + } +} + +impl Write for BoundedDecodeSink { + fn write(&mut self, data: &[u8]) -> io::Result { + let next = self + .decoded_bytes + .get() + .checked_add(data.len()) + .ok_or_else(|| { + io::Error::other("publisher origin body decoded byte count overflowed") + })?; + if next > self.max_decoded_bytes { + return Err(io::Error::other(format!( + "publisher origin body decoded size exceeded {}-byte streaming limit", + self.max_decoded_bytes + ))); + } + self.decoded_bytes.set(next); + self.buffer.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Charge `len` decoded bytes against `decoded_bytes`, erroring if the +/// cumulative total would exceed `max_decoded_bytes`. +fn charge_decoded( + decoded_bytes: &Cell, + max_decoded_bytes: usize, + len: usize, +) -> Result<(), Report> { + let next = decoded_bytes.get().checked_add(len).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin body decoded byte count overflowed".to_string(), + }) + })?; + if next > max_decoded_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "publisher origin body decoded size exceeded {max_decoded_bytes}-byte streaming limit" + ), + })); + } + decoded_bytes.set(next); + Ok(()) } /// Streaming zlib decoder that tracks whether the stream reached its end @@ -388,22 +470,40 @@ enum BodyStreamDecoderCodec { struct DeflateStreamDecoder { decompress: flate2::Decompress, stream_ended: bool, + decoded_bytes: Rc>, + max_decoded_bytes: usize, } impl DeflateStreamDecoder { - fn new() -> Self { + fn new(decoded_bytes: Rc>, max_decoded_bytes: usize) -> Self { Self { decompress: flate2::Decompress::new(true), stream_ended: false, + decoded_bytes, + max_decoded_bytes, } } + /// Charge `len` decoded bytes against the shared budget. + fn charge(&self, len: usize) -> Result<(), Report> { + charge_decoded(&self.decoded_bytes, self.max_decoded_bytes, len) + } + + /// Decode as much of `chunk` as possible, draining any output the inflater + /// can still produce once all input is consumed. + /// + /// flate2 fills the output buffer up to its capacity, so a chunk that + /// exactly fills the buffer leaves decoded bytes (and possibly the + /// end-of-stream marker) pending with all input already consumed. The loop + /// keeps driving the inflater — reserving more output space — until it + /// makes no further progress, so those pending bytes are never stranded and + /// a valid stream is not mistaken for a truncated one at `finish`. fn decode(&mut self, chunk: &[u8]) -> Result, Report> { let mut output = Vec::with_capacity(STREAM_CHUNK_SIZE); let mut offset = 0usize; // Trailing bytes after the zlib end marker are ignored, matching the // read-based decoder used by the buffered pipeline. - while offset < chunk.len() && !self.stream_ended { + while !self.stream_ended { if output.len() == output.capacity() { output.reserve(STREAM_CHUNK_SIZE); } @@ -418,36 +518,85 @@ impl DeflateStreamDecoder { let consumed = (self.decompress.total_in() - before_in) as usize; let produced = (self.decompress.total_out() - before_out) as usize; offset += consumed; + self.charge(produced)?; match status { flate2::Status::StreamEnd => self.stream_ended = true, flate2::Status::Ok | flate2::Status::BufError => { + // Stop only when the inflater is starved for input: it made + // no progress and there is still spare output capacity, so + // the stall is missing input (arriving in a later chunk, or + // resolved at `finish`), not an exhausted output buffer. if consumed == 0 && produced == 0 && output.len() < output.capacity() { - return Err(Report::new(TrustedServerError::Proxy { - message: "deflate publisher body decoder made no progress".to_string(), - })); + break; } } } } Ok(output) } + + /// Drive the inflater to completion at end of input, draining the final + /// decoded bytes and validating the end-of-stream marker. + /// + /// A valid stream whose last decoded byte exactly filled the previous + /// output buffer still has its end marker pending here; a genuinely + /// truncated stream makes no further progress and errors. + fn finish(&mut self) -> Result, Report> { + let mut output = Vec::new(); + while !self.stream_ended { + if output.len() == output.capacity() { + output.reserve(STREAM_CHUNK_SIZE); + } + let before_out = self.decompress.total_out(); + let status = self + .decompress + .decompress_vec(&[], &mut output, flate2::FlushDecompress::Finish) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body decoder".to_string(), + })?; + let produced = (self.decompress.total_out() - before_out) as usize; + self.charge(produced)?; + match status { + flate2::Status::StreamEnd => self.stream_ended = true, + flate2::Status::Ok | flate2::Status::BufError => { + if produced == 0 { + break; + } + } + } + } + if !self.stream_ended { + return Err(Report::new(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body decoder: truncated stream" + .to_string(), + })); + } + Ok(output) + } } impl BodyStreamDecoder { pub(crate) fn new(compression: Compression, max_decoded_bytes: usize) -> Self { + let decoded_bytes = Rc::new(Cell::new(0usize)); let codec = match compression { Compression::None => BodyStreamDecoderCodec::None, - Compression::Gzip => { - BodyStreamDecoderCodec::Gzip(flate2::write::GzDecoder::new(Vec::new())) - } - Compression::Deflate => BodyStreamDecoderCodec::Deflate(DeflateStreamDecoder::new()), - Compression::Brotli => BodyStreamDecoderCodec::Brotli(Box::new( - brotli::DecompressorWriter::new(Vec::new(), STREAM_CHUNK_SIZE), + Compression::Gzip => BodyStreamDecoderCodec::Gzip(flate2::write::MultiGzDecoder::new( + BoundedDecodeSink::new(Rc::clone(&decoded_bytes), max_decoded_bytes), + )), + Compression::Deflate => BodyStreamDecoderCodec::Deflate(DeflateStreamDecoder::new( + Rc::clone(&decoded_bytes), + max_decoded_bytes, )), + Compression::Brotli => { + BodyStreamDecoderCodec::Brotli(Box::new(brotli::DecompressorWriter::new( + BoundedDecodeSink::new(Rc::clone(&decoded_bytes), max_decoded_bytes), + STREAM_CHUNK_SIZE, + ))) + } }; Self { codec, - decoded_bytes: 0, + decoded_bytes, max_decoded_bytes, } } @@ -456,51 +605,52 @@ impl BodyStreamDecoder { &mut self, chunk: bytes::Bytes, ) -> Result> { - let decoded = match &mut self.codec { - BodyStreamDecoderCodec::None => chunk, + match &mut self.codec { + BodyStreamDecoderCodec::None => { + // No sink guards the pass-through path, so charge the raw chunk + // directly against the shared budget. + charge_decoded(&self.decoded_bytes, self.max_decoded_bytes, chunk.len())?; + Ok(chunk) + } BodyStreamDecoderCodec::Gzip(decoder) => { decoder .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode gzip publisher body chunk".to_string(), })?; - bytes::Bytes::from(std::mem::take(decoder.get_mut())) + // The sink charged the decoded bytes during `write_all`. + Ok(bytes::Bytes::from(std::mem::take( + &mut decoder.get_mut().buffer, + ))) + } + BodyStreamDecoderCodec::Deflate(decoder) => { + Ok(bytes::Bytes::from(decoder.decode(&chunk)?)) } - BodyStreamDecoderCodec::Deflate(decoder) => bytes::Bytes::from(decoder.decode(&chunk)?), BodyStreamDecoderCodec::Brotli(decoder) => { decoder .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode brotli publisher body chunk".to_string(), })?; - bytes::Bytes::from(std::mem::take(decoder.get_mut())) + Ok(bytes::Bytes::from(std::mem::take( + &mut decoder.get_mut().buffer, + ))) } - }; - self.track_decoded(decoded.len())?; - Ok(decoded) + } } pub(crate) fn finish(&mut self) -> Result, Report> { - let tail = match &mut self.codec { - BodyStreamDecoderCodec::None => Vec::new(), + match &mut self.codec { + BodyStreamDecoderCodec::None => Ok(Vec::new()), BodyStreamDecoderCodec::Gzip(decoder) => { decoder .try_finish() .change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip publisher body decoder".to_string(), })?; - std::mem::take(decoder.get_mut()) - } - BodyStreamDecoderCodec::Deflate(decoder) => { - if !decoder.stream_ended { - return Err(Report::new(TrustedServerError::Proxy { - message: - "Failed to finalize deflate publisher body decoder: truncated stream" - .to_string(), - })); - } - Vec::new() + Ok(std::mem::take(&mut decoder.get_mut().buffer)) } + BodyStreamDecoderCodec::Deflate(decoder) => decoder.finish(), BodyStreamDecoderCodec::Brotli(decoder) => { // `close()` (not `flush()`): flush accepts a truncated brotli // stream silently, while close validates end-of-stream and @@ -508,28 +658,9 @@ impl BodyStreamDecoder { decoder.close().change_context(TrustedServerError::Proxy { message: "Failed to finalize brotli publisher body decoder".to_string(), })?; - std::mem::take(decoder.get_mut()) + Ok(std::mem::take(&mut decoder.get_mut().buffer)) } - }; - self.track_decoded(tail.len())?; - Ok(tail) - } - - fn track_decoded(&mut self, len: usize) -> Result<(), Report> { - self.decoded_bytes = self.decoded_bytes.checked_add(len).ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "publisher origin body decoded byte count overflowed".to_string(), - }) - })?; - if self.decoded_bytes > self.max_decoded_bytes { - return Err(Report::new(TrustedServerError::Proxy { - message: format!( - "publisher origin body decoded size exceeded {}-byte streaming limit", - self.max_decoded_bytes - ), - })); } - Ok(()) } } @@ -704,6 +835,132 @@ mod tests { ); } + #[test] + fn body_stream_decoder_decodes_deflate_filling_output_buffer_exactly() { + // A decoded length one byte past the decoder's internal output buffer + // (`STREAM_CHUNK_SIZE`) hits the boundary where flate2 consumes all + // input while exactly filling the output buffer and returns + // `Status::Ok` with the stream-end marker still pending. The decoder + // must drive the inflater to completion instead of reporting a + // truncated stream. + let payload = vec![b'a'; STREAM_CHUNK_SIZE + 1]; + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(&payload) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + + let mut decoded = decoder + .decode_chunk(bytes::Bytes::from(compressed)) + .expect("complete deflate stream should decode") + .to_vec(); + decoded.extend( + decoder + .finish() + .expect("a complete deflate stream must not report truncation"), + ); + + assert_eq!( + decoded, payload, + "should decode the full payload across the output-buffer boundary" + ); + } + + #[test] + fn body_stream_decoder_decodes_deflate_split_across_many_chunks() { + let payload = vec![b'x'; STREAM_CHUNK_SIZE * 3 + 7]; + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(&payload) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + + let mut decoded = Vec::new(); + // Feed the compressed stream a few bytes at a time to exercise many + // input split points, including splits inside the end-of-stream marker. + for piece in compressed.chunks(3) { + decoded.extend( + decoder + .decode_chunk(bytes::Bytes::copy_from_slice(piece)) + .expect("partial deflate input should decode incrementally"), + ); + } + decoded.extend( + decoder + .finish() + .expect("a complete deflate stream must finalize"), + ); + + assert_eq!( + decoded, payload, + "should decode the full payload regardless of input split points" + ); + } + + fn gzip_member(data: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(data) + .expect("should write gzip test input"); + encoder.finish().expect("should finish gzip encoding") + } + + #[test] + fn body_stream_decoder_decodes_multi_member_gzip_single_chunk() { + let mut compressed = gzip_member(b"first member "); + compressed.extend(gzip_member(b"second member")); + let mut decoder = BodyStreamDecoder::new(Compression::Gzip, usize::MAX); + + let mut decoded = decoder + .decode_chunk(bytes::Bytes::from(compressed)) + .expect("a multi-member gzip body must decode all members") + .to_vec(); + decoded.extend( + decoder + .finish() + .expect("a multi-member gzip body must finalize"), + ); + + assert_eq!( + decoded, b"first member second member", + "should concatenate the decoded output of every gzip member" + ); + } + + #[test] + fn body_stream_decoder_decodes_multi_member_gzip_split_across_chunks() { + let mut compressed = gzip_member(b"alpha"); + compressed.extend(gzip_member(b"omega")); + let mut decoder = BodyStreamDecoder::new(Compression::Gzip, usize::MAX); + + let mut decoded = Vec::new(); + for piece in compressed.chunks(4) { + decoded.extend( + decoder + .decode_chunk(bytes::Bytes::copy_from_slice(piece)) + .expect("multi-member gzip should decode across chunk boundaries"), + ); + } + decoded.extend( + decoder + .finish() + .expect("a multi-member gzip body must finalize"), + ); + + assert_eq!( + decoded, b"alphaomega", + "should decode both gzip members split across chunk boundaries" + ); + } + /// Verify that `lol_html` fragments text nodes when input chunks split /// mid-text-node. Script rewriters must be fragment-safe — they accumulate /// text fragments internally until `is_last_in_text_node` is true. From 5512d8507cbfeee9777ddbb09be2f44ba734fc86 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 18:10:20 +0530 Subject: [PATCH 020/494] Address auction transport-timeout review findings Resolve the PR review by making transport-timeout canonicalization a platform capability and hardening auction backend-name correlation. - Move quantization behind PlatformBackend::canonicalize_transport_timeout_ms. Fastly floors budget-derived timeouts to a 250ms quantum with a bounded sub-quantum ladder [200,150,100,50]; other adapters use the exact remaining budget so bidder deadlines (Prebid tmax, APS timeout) are not shortened where no connection-pooling benefit exists. - Bound sub-quantum backend-name cardinality: exact 1-249ms values no longer pass through, capping the budget-derived names a single origin can mint toward the per-service dynamic backend limit. - Add a provider discriminator to PlatformBackendSpec, folded into every adapter's backend name, so two providers sharing one origin no longer collide on the response-correlation key. Reject a duplicate backend_to_provider insertion with an attributed launch failure instead of silently overwriting and misattributing a response. - Make the orchestrator call-site tests deterministic: record predicted and registered transport timeouts separately and assert exact equality via a controllable platform backend, and enumerate the sub-quantum ladder to assert a bounded name cardinality. - Correct the timeout-semantics comments that overstated absolute-deadline enforcement; the Fastly connect/first-byte/between-bytes timeouts bound connection, first-byte, and inactivity, not total response time. A true absolute deadline carried through the platform HTTP API remains follow-up work (#849). --- .../src/platform.rs | 12 +- .../src/platform.rs | 9 +- .../src/backend.rs | 35 +- .../src/platform.rs | 200 +++++ .../src/tinybird.rs | 1 + .../src/platform.rs | 9 +- .../src/auction/orchestrator.rs | 794 ++++++++++-------- .../trusted-server-core/src/ec/pull_sync.rs | 1 + .../src/integrations/datadome/protection.rs | 1 + .../src/integrations/mod.rs | 4 + .../src/platform/test_support.rs | 28 + .../src/platform/traits.rs | 22 + .../trusted-server-core/src/platform/types.rs | 10 + crates/trusted-server-core/src/proxy.rs | 2 + crates/trusted-server-core/src/publisher.rs | 1 + 15 files changed, 772 insertions(+), 357 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 461b567d1..a511daab2 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -158,11 +158,19 @@ impl PlatformBackend for AxumPlatformBackend { let port = spec .port .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{}", normalize_env_segment(d))) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}", + "{}_{}_{}{}", normalize_env_segment(&spec.scheme), normalize_env_segment(&spec.host), port, + discriminator, )) } @@ -644,6 +652,7 @@ mod tests { first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), host_header_override: None, + discriminator: None, }; let name1 = backend.predict_name(&spec).expect("should return a name"); let name2 = backend @@ -664,6 +673,7 @@ mod tests { first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), host_header_override: None, + discriminator: None, }; assert_eq!( backend.predict_name(&spec).expect("should return name"), diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index a01c4d979..9467abb71 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -71,8 +71,15 @@ impl PlatformBackend for NoopBackend { } else { "_nocert" }; + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{d}")) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", spec.scheme, spec.host, port )) } diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index 4056c81da..7205a8a00 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -64,6 +64,7 @@ pub struct BackendConfig<'a> { first_byte_timeout: Duration, between_bytes_timeout: Duration, host_header_override: Option<&'a str>, + discriminator: Option<&'a str>, } impl<'a> BackendConfig<'a> { @@ -81,6 +82,7 @@ impl<'a> BackendConfig<'a> { first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_BETWEEN_BYTES_TIMEOUT, host_header_override: None, + discriminator: None, } } @@ -128,14 +130,30 @@ impl<'a> BackendConfig<'a> { self } + /// Set an optional stable discriminator folded into the backend name. + /// + /// Two callers targeting the same origin with the same transport timeout + /// otherwise share a backend name. Auction response correlation keys on the + /// backend name, so a shared name would let one provider's response be + /// parsed as another's. A per-provider discriminator keeps the names + /// distinct while staying stable across requests. + #[must_use] + pub fn discriminator(mut self, discriminator: Option<&'a str>) -> Self { + self.discriminator = discriminator; + self + } + /// Compute the deterministic backend name and resolved port without /// registering anything. /// - /// The name encodes scheme, host, port, certificate setting, and - /// first-byte timeout so that backends with different configurations - /// never collide. Including the timeout prevents "first-registration-wins" - /// poisoning where a later request for the same origin with a tighter - /// timeout would silently inherit the original registration's value. + /// The name encodes scheme, host, port, certificate setting, optional + /// discriminator, and the first-byte/between-bytes timeouts so that + /// backends with different configurations never collide. Including the + /// timeout prevents "first-registration-wins" poisoning where a later + /// request for the same origin with a tighter timeout would silently + /// inherit the original registration's value. Including the discriminator + /// keeps two callers that target the same origin with the same timeout + /// (e.g. two auction providers behind one gateway) on distinct backends. fn compute_name(&self) -> Result<(String, u16), Report> { if self.host.is_empty() { return Err(Report::new(TrustedServerError::Proxy { @@ -174,13 +192,18 @@ impl<'a> BackendConfig<'a> { } else { "_nocert" }; + let discriminator_suffix = self + .discriminator + .map(|d| format!("_p_{}", sanitize_backend_name_component(d))) + .unwrap_or_default(); let first_byte_timeout_ms = self.first_byte_timeout.as_millis(); let between_bytes_timeout_ms = self.between_bytes_timeout.as_millis(); let backend_name = format!( - "backend_{}{}{}_fb{}_bb{}", + "backend_{}{}{}{}_fb{}_bb{}", sanitize_backend_name_component(&name_base), host_override_suffix, cert_suffix, + discriminator_suffix, first_byte_timeout_ms, between_bytes_timeout_ms ); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index c5bb60b9c..c89508d36 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -156,6 +156,40 @@ fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { .certificate_check(spec.certificate_check) .first_byte_timeout(spec.first_byte_timeout) .between_bytes_timeout(spec.between_bytes_timeout) + .discriminator(spec.discriminator.as_deref()) +} + +/// Transport-timeout quantum for auction backends (see +/// [`FastlyPlatformBackend::canonicalize_transport_timeout_ms`]). +const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; + +/// Coarse rungs for budget-bound transport timeouts below one quantum, +/// ordered high to low. +/// +/// A budget-bound value at or above one quantum is floored to a +/// [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple. Below one quantum, passing the +/// exact wall-clock remainder through would mint a distinct backend name for +/// every millisecond in `1..250`, so the near-exhausted tail alone could +/// exceed Fastly's per-service dynamic backend limit. Snapping to this finite +/// ladder instead bounds the number of budget-derived names an origin can +/// produce. Budgets below the smallest rung round to zero, which callers treat +/// as "budget exhausted — skip the launch". +const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; + +/// Round a budget-bound transport timeout down to a stable bucket. +/// +/// At or above one quantum, floors to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] +/// multiple. Below one quantum, snaps down to the greatest +/// [`SUB_QUANTUM_LADDER_MS`] rung no larger than `remaining_ms` (or zero). +fn quantize_transport_timeout_ms(remaining_ms: u32) -> u32 { + let floored = (remaining_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS; + if floored > 0 { + return floored; + } + SUB_QUANTUM_LADDER_MS + .into_iter() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(0) } impl PlatformBackend for FastlyPlatformBackend { @@ -170,6 +204,28 @@ impl PlatformBackend for FastlyPlatformBackend { .ensure() .change_context(PlatformError::Backend) } + + /// Quantize the transport timeout so budget-derived values do not mint a + /// new dynamic backend name on every request. + /// + /// Fastly embeds the first-byte and between-bytes timeouts in the dynamic + /// backend name (see [`BackendConfig`]) and pools connections per backend + /// name. A per-request wall-clock budget would otherwise defeat that + /// pooling and accumulate registrations toward the per-service dynamic + /// backend limit. + /// + /// A provider's own configured timeout is a constant, so when it is the + /// binding constraint it is returned verbatim — including sub-quantum + /// configured values, which must not be rounded away or the provider could + /// never launch. Only the budget-bound value is snapped to a stable bucket + /// via [`quantize_transport_timeout_ms`]. Rounding down never extends a + /// transport cap past the remaining budget. + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + quantize_transport_timeout_ms(remaining_ms) + } } // --------------------------------------------------------------------------- @@ -637,6 +693,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -660,6 +717,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -683,6 +741,7 @@ mod tests { certificate_check: false, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -706,6 +765,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let result = backend.predict_name(&spec); @@ -724,6 +784,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_millis(2000), between_bytes_timeout: Duration::from_millis(2000), + discriminator: None, }; let name = backend @@ -752,6 +813,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_millis(750), between_bytes_timeout: Duration::from_millis(750), + discriminator: None, }; let predicted = backend @@ -937,4 +999,142 @@ mod tests { "should describe the unsupported streaming body: {err:?}" ); } + + // --- FastlyPlatformBackend::canonicalize_transport_timeout_ms ----------- + + #[test] + fn canonicalize_prefers_configured_timeout_when_budget_allows() { + let backend = FastlyPlatformBackend; + assert_eq!( + backend.canonicalize_transport_timeout_ms(2000, 1000), + 1000, + "should use the configured timeout verbatim when the budget allows" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(2000, 100), + 100, + "should preserve a sub-quantum configured constant — it is name-stable on its own" + ); + } + + #[test] + fn canonicalize_floors_budget_bound_value_to_quantum() { + let backend = FastlyPlatformBackend; + assert_eq!( + backend.canonicalize_transport_timeout_ms(999, 2000), + 750, + "should floor a 999ms budget to the 750ms quantum bucket" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(300, 2000), + 250, + "should floor a tight budget down to one quantum" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(250, 2000), + 250, + "should keep an exact quantum multiple" + ); + } + + #[test] + fn canonicalize_snaps_sub_quantum_budget_to_bounded_ladder() { + let backend = FastlyPlatformBackend; + // Exact wall-clock values in 1..250 must NOT pass through — that is the + // unbounded-cardinality regression this ladder closes. + assert_eq!( + backend.canonicalize_transport_timeout_ms(249, 2000), + 200, + "should snap a sub-quantum budget down to the greatest ladder rung, not pass 249 through" + ); + assert_eq!(backend.canonicalize_transport_timeout_ms(200, 2000), 200); + assert_eq!(backend.canonicalize_transport_timeout_ms(150, 2000), 150); + assert_eq!(backend.canonicalize_transport_timeout_ms(100, 2000), 100); + assert_eq!(backend.canonicalize_transport_timeout_ms(50, 2000), 50); + assert_eq!( + backend.canonicalize_transport_timeout_ms(49, 2000), + 0, + "a budget below the smallest rung rounds to zero (launch skipped)" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(0, 1000), + 0, + "an exhausted budget canonicalizes to zero" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(100, 0), + 0, + "a zero configured timeout canonicalizes to zero" + ); + } + + #[test] + fn canonicalize_budget_derived_names_stay_within_a_safe_cardinality() { + // Enumerate every reachable remaining budget for a normal 2000ms + // ceiling and confirm the number of distinct backend-name-bearing + // transport values an origin can mint stays far below Fastly's + // per-service dynamic backend limit (documented default 200). + let backend = FastlyPlatformBackend; + let configured = 2000; + let mut distinct = std::collections::BTreeSet::new(); + for remaining in 0..=configured { + let value = backend.canonicalize_transport_timeout_ms(remaining, configured); + if value > 0 { + distinct.insert(value); + } + // No arbitrary clock-derived value may leak: every canonical value + // is either a quantum multiple or one of the bounded ladder rungs. + assert!( + value == 0 + || value % TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + || SUB_QUANTUM_LADDER_MS.contains(&value), + "canonical value {value}ms (from remaining {remaining}ms) is neither a quantum \ + multiple nor a ladder rung" + ); + } + assert!( + distinct.len() <= 16, + "budget-derived transport values should stay well under the dynamic backend limit, \ + got {} distinct values: {distinct:?}", + distinct.len() + ); + } + + // --- FastlyPlatformBackend::predict_name discriminator ------------------ + + #[test] + fn predict_name_includes_provider_discriminator() { + let backend = FastlyPlatformBackend; + let base = PlatformBackendSpec { + scheme: "https".to_string(), + host: "gateway.example.com".to_string(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + discriminator: Some("prebid".to_string()), + }; + let prebid_name = backend + .predict_name(&base) + .expect("should predict name with discriminator"); + assert!( + prebid_name.contains("_p_prebid"), + "should fold the provider discriminator into the name, got {prebid_name}" + ); + + // Same origin + same transport timeout, different provider → distinct + // backend names, so auction response correlation cannot cross them. + let aps = PlatformBackendSpec { + discriminator: Some("aps".to_string()), + ..base.clone() + }; + let aps_name = backend + .predict_name(&aps) + .expect("should predict name for the second provider"); + assert_ne!( + prebid_name, aps_name, + "two providers on one origin must not share a backend name" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index b5d332a65..8df6dbe6e 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -212,6 +212,7 @@ fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { certificate_check: true, first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, between_bytes_timeout: TINYBIRD_BETWEEN_BYTES_TIMEOUT, + discriminator: None, } } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 1e13ca300..492f1a518 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -92,8 +92,15 @@ impl PlatformBackend for NoopBackend { } else { "_nocert" }; + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{d}")) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", spec.scheme, spec.host, port )) } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index d884a0220..02a87cdb5 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -157,64 +157,6 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } -/// Transport-timeout quantum for auction backends. -/// -/// See [`quantize_transport_timeout_ms`] for why provider transport timeouts -/// are rounded to this granularity. -const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; - -/// Round a transport timeout down to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple. -/// -/// The Fastly adapter embeds the first-byte and between-bytes timeouts in the -/// dynamic backend name so a registration can never be silently reused with a -/// different transport configuration. Deriving those timeouts from the -/// remaining wall-clock budget minted a new backend name on nearly every -/// request, which defeated cross-request TCP/TLS connection reuse (Fastly -/// pools connections per backend name) and accumulated registrations toward -/// the per-service dynamic backend limit. -/// -/// Quantizing the value — not just the name — keeps the registered backend -/// configuration aligned with its name. Rounding down never extends a -/// transport cap past the auction deadline, which matters on the mediator and -/// dispatched-collect paths where the backend timeouts (not a select-loop -/// deadline check) bound the `` hold. -#[inline] -fn quantize_transport_timeout_ms(timeout_ms: u32) -> u32 { - (timeout_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS -} - -/// Compute the transport timeout for a provider launch from the remaining -/// auction budget and the provider's configured timeout. -/// -/// The configured timeout is a per-provider constant, so using it verbatim -/// already yields a stable backend name — including configured values below -/// one quantum, which must not be rounded away or the provider could never -/// launch. Only when the remaining budget is the binding constraint does the -/// wall-clock-derived value enter the name, and that value is quantized via -/// [`quantize_transport_timeout_ms`] so it cannot mint a new backend name on -/// every request. -/// -/// A remaining budget below one quantum is passed through exactly rather -/// than rounded to zero: rounding up would extend the transport cap past the -/// deadline, and rounding down would skip the launch and hard-fail auctions -/// whose configured budget is under one quantum. Name churn in this regime -/// is bounded to sub-quantum values and matches the pre-quantization -/// behavior. The result never exceeds `remaining_ms` and is zero only when -/// `remaining_ms` or `configured_ms` is zero, which callers treat as -/// "budget exhausted — skip the launch". -#[inline] -fn effective_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { - if remaining_ms >= configured_ms { - return configured_ms; - } - let quantized = quantize_transport_timeout_ms(remaining_ms); - if quantized == 0 { - remaining_ms - } else { - quantized - } -} - /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, @@ -338,11 +280,16 @@ impl AuctionOrchestrator { // Give the mediator only the remaining time from the auction // deadline, not the full timeout — the bidding phase already // consumed part of it, and the mediator has no select-loop - // deadline backstop. Quantized for backend-name stability (see - // effective_transport_timeout_ms). + // deadline backstop. The platform canonicalizes the value for + // backend-name stability (see + // `PlatformBackend::canonicalize_transport_timeout_ms`); it never + // exceeds the remaining budget. See the transport-deadline note on + // `run_providers_parallel` for the limits of this bound. let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - let mediator_timeout = - effective_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); + let mediator_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); if mediator_timeout == 0 { log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); @@ -525,11 +472,14 @@ impl AuctionOrchestrator { // Give each provider only the remaining time from the auction // deadline so that backend transport timeouts do not extend past - // the overall budget, quantized for backend-name stability (see - // effective_transport_timeout_ms). + // the overall budget. The platform canonicalizes the value for + // backend-name stability (see + // `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = - effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); + let effective_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); @@ -580,15 +530,35 @@ impl AuctionOrchestrator { ); backend_name.clone() }); - backend_to_provider.insert( - request_backend_name.clone(), - (provider.provider_name(), start_time, provider.as_ref()), - ); - pending_requests.push(pending); - log::debug!( - "Request to '{}' launched successfully", - provider.provider_name() - ); + // Responses are correlated back to providers by backend + // name. If another provider this auction already claimed + // this name (e.g. two providers on one origin whose specs + // canonicalize to the same backend), inserting here would + // silently overwrite the first mapping and misattribute or + // drop a response. Fail this launch attributably instead. + if backend_to_provider.contains_key(&request_backend_name) { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!( + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping launch to avoid response misattribution", + provider.provider_name(), + request_backend_name, + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); + } else { + backend_to_provider.insert( + request_backend_name.clone(), + (provider.provider_name(), start_time, provider.as_ref()), + ); + pending_requests.push(pending); + log::debug!( + "Request to '{}' launched successfully", + provider.provider_name() + ); + } } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -621,14 +591,25 @@ impl AuctionOrchestrator { ); // Phase 2: Wait for responses using select() to process as they become ready. - // Enforce the auction deadline: after each select() returns, check - // elapsed time and drop remaining requests if the timeout is exceeded. + // After each select() returns, check elapsed time and drop remaining + // requests once the auction deadline passes. // - // NOTE: `select()` blocks until at least one backend responds and, on - // some adapters, buffers the selected response body before returning. - // Hard deadline enforcement therefore depends on every backend's - // first-byte and between-bytes timeouts being set to at most the - // remaining auction budget, which Phase 1 above guarantees. + // TRANSPORT-DEADLINE NOTE: this select loop is the only *absolute* + // wall-clock bound on the parallel path — it drops still-pending + // requests once `auction_start.elapsed()` exceeds the deadline. The + // per-backend transport timeouts set in Phase 1 are a complementary, + // not equivalent, bound: Fastly's connect timeout is a fixed ~1s, the + // first-byte timeout only starts after the connection is established, + // and the between-bytes timeout is an inactivity timer that resets on + // every byte received. A backend that connects slowly or trickles one + // byte just inside the between-bytes window can therefore outlive the + // configured budget. Bounding them to the remaining budget (Phase 1) + // guarantees they never *extend past* the deadline by their own + // configuration, but does not by itself enforce a hard total-response + // deadline. Paths without this select loop (the mediator and the + // dispatched-collect body read) inherit that weaker bound; a true + // absolute deadline carried through the platform HTTP API is tracked + // as follow-up work (see the streaming/deadline effort, #849). let mut remaining = pending_requests; while !remaining.is_empty() { @@ -937,11 +918,13 @@ impl AuctionOrchestrator { continue; } - // Remaining budget quantized for backend-name stability (see - // effective_transport_timeout_ms). + // Remaining budget canonicalized by the platform for backend-name + // stability (see `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = - effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); + let effective_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!( @@ -974,21 +957,39 @@ impl AuctionOrchestrator { let start_time = Instant::now(); match provider.request_bids(request, &provider_context).await { Ok(pending) => { - log::info!( - "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", - provider.provider_name(), - backend_name, - effective_timeout - ); - backend_to_provider.insert( - backend_name.clone(), - ( - provider.provider_name().to_string(), - start_time, - Arc::clone(provider), - ), - ); - pending_requests.push(pending.with_backend_name(backend_name)); + // See the parallel path: a backend name already claimed by + // another provider this auction would misattribute the + // collected response, so fail this launch attributably + // rather than overwrite the mapping. + if backend_to_provider.contains_key(&backend_name) { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!( + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping dispatch to avoid response misattribution", + provider.provider_name(), + backend_name, + ); + launch_responses.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); + } else { + log::info!( + "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", + provider.provider_name(), + backend_name, + effective_timeout + ); + backend_to_provider.insert( + backend_name.clone(), + ( + provider.provider_name().to_string(), + start_time, + Arc::clone(provider), + ), + ); + pending_requests.push(pending.with_backend_name(backend_name)); + } } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -1189,21 +1190,27 @@ impl AuctionOrchestrator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). The old - // comment here claimed origin drain could exhaust the budget before - // collection, but SSP backends are given first-byte and between-bytes - // timeouts equal to effective_timeout (capped at their provider - // timeout) at dispatch time, so they cannot run past A_deadline - // independently. Giving the mediator an uncapped timeout lets it run - // past A_deadline, violating the bounded hold invariant. - // The mediator's only time bound on this path is its - // backend transport timeout, so the effective value must - // never exceed the remaining budget. Quantized for - // backend-name stability (see - // effective_transport_timeout_ms). + // timeout or the remaining auction budget (A_deadline). Giving + // the mediator an uncapped timeout would let it hold `` + // well past A_deadline, so the effective value must never + // exceed the remaining budget. + // + // Caveat: unlike the parallel select loop, this path has no + // absolute wall-clock backstop around the mediator call, and a + // backend transport timeout bounds first-byte/inactivity rather + // than total response time (see the transport-deadline note on + // `run_providers_parallel`). Capping the value to the remaining + // budget therefore prevents the mediator from *extending* the + // hold by its own configuration, but a slow-connecting or + // byte-trickling mediator can still overrun; a true absolute + // deadline is tracked as follow-up (#849). + // + // The platform canonicalizes the value for backend-name + // stability (see `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining = remaining_budget_ms(auction_start, timeout_ms); - let mediator_timeout = - effective_transport_timeout_ms(remaining, mediator.timeout_ms()); + let mediator_timeout = services + .backend() + .canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms()); if mediator_timeout == 0 { log::warn!( "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", @@ -1397,9 +1404,13 @@ mod tests { MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; - use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::platform::test_support::{ + build_services_with_backend_and_http_client, build_services_with_http_client, + StubHttpClient, + }; use crate::platform::{ - PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, + PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, RuntimeServices, }; use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; @@ -1412,15 +1423,19 @@ mod tests { // Minimal test double for AuctionProvider // --------------------------------------------------------------------------- - /// Minimal stub provider. Optionally records every transport timeout it - /// observes — the value passed to `backend_name` and the - /// `context.timeout_ms` handed to `request_bids` — so tests can assert - /// the orchestrator quantizes them. + /// Minimal stub provider. Optionally records the transport timeouts it + /// observes, keeping the value passed to `backend_name` (which derives the + /// predicted backend name) separate from the `context.timeout_ms` handed to + /// `request_bids` (which configures the registered request). Recording them + /// separately lets tests assert the orchestrator hands the *same* + /// canonicalized value to both — a divergence would land responses in the + /// "unknown backend" branch and drop bids. struct StubAuctionProvider { name: &'static str, backend: &'static str, configured_timeout_ms: u32, - observed_timeouts: Option>>>, + predicted_timeouts: Option>>>, + request_timeouts: Option>>>, } impl StubAuctionProvider { @@ -1429,7 +1444,8 @@ mod tests { name, backend, configured_timeout_ms: 2000, - observed_timeouts: None, + predicted_timeouts: None, + request_timeouts: None, } } @@ -1437,18 +1453,20 @@ mod tests { name: &'static str, backend: &'static str, configured_timeout_ms: u32, - observed_timeouts: Arc>>, + predicted_timeouts: Arc>>, + request_timeouts: Arc>>, ) -> Self { Self { name, backend, configured_timeout_ms, - observed_timeouts: Some(observed_timeouts), + predicted_timeouts: Some(predicted_timeouts), + request_timeouts: Some(request_timeouts), } } - fn record(&self, timeout_ms: u32) { - if let Some(observed) = &self.observed_timeouts { + fn record(slot: &Option>>>, timeout_ms: u32) { + if let Some(observed) = slot { observed .lock() .expect("should lock observed timeouts") @@ -1468,7 +1486,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - self.record(context.timeout_ms); + Self::record(&self.request_timeouts, context.timeout_ms); let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") @@ -1504,7 +1522,7 @@ mod tests { } fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { - self.record(timeout_ms); + Self::record(&self.predicted_timeouts, timeout_ms); Some(self.backend.to_string()) } } @@ -2038,94 +2056,71 @@ mod tests { ); } - #[test] - fn quantize_transport_timeout_floors_to_quantum() { - assert_eq!( - super::quantize_transport_timeout_ms(0), - 0, - "should keep zero at zero" - ); - assert_eq!( - super::quantize_transport_timeout_ms(249), - 0, - "should floor a sub-quantum budget to zero" - ); - assert_eq!( - super::quantize_transport_timeout_ms(250), - 250, - "should keep an exact quantum multiple unchanged" - ); - assert_eq!( - super::quantize_transport_timeout_ms(999), - 750, - "should floor to the next-lower quantum multiple" - ); - assert_eq!( - super::quantize_transport_timeout_ms(2000), - 2000, - "should keep a larger exact quantum multiple unchanged" - ); + /// Test backend whose [`PlatformBackend::canonicalize_transport_timeout_ms`] + /// returns a fixed value regardless of the wall-clock budget, so the + /// orchestrator's transport-timeout wiring can be asserted without timing + /// flakiness. Records every `(remaining_ms, configured_ms)` pair it sees. + /// + /// The exact quantization arithmetic lives in the Fastly adapter (the only + /// platform that overrides `canonicalize_transport_timeout_ms`); these core + /// tests only prove the orchestrator applies whatever the platform returns + /// and applies it identically to the predicted name and the launched + /// request. + struct CanonicalTimeoutBackend { + canonical_ms: u32, + calls: Arc>>, } - #[test] - fn effective_transport_timeout_prefers_configured_constant() { - assert_eq!( - super::effective_transport_timeout_ms(2000, 1000), - 1000, - "should use the configured timeout verbatim when the budget allows" - ); - assert_eq!( - super::effective_transport_timeout_ms(2000, 100), - 100, - "should preserve a sub-quantum configured timeout — quantizing it away would permanently disable the provider" - ); - assert_eq!( - super::effective_transport_timeout_ms(999, 2000), - 750, - "should quantize the budget-bound value down to the 750ms bucket" - ); - assert_eq!( - super::effective_transport_timeout_ms(300, 2000), - 250, - "should quantize a tight budget down to one quantum" - ); - assert_eq!( - super::effective_transport_timeout_ms(200, 2000), - 200, - "should pass a sub-quantum budget through exactly instead of rounding to zero" - ); - assert_eq!( - super::effective_transport_timeout_ms(50, 100), - 50, - "should pass through when the budget is below both the quantum and the configured timeout" - ); - assert_eq!( - super::effective_transport_timeout_ms(0, 1000), - 0, - "should return zero for an exhausted budget so the launch is skipped" - ); - assert_eq!( - super::effective_transport_timeout_ms(100, 0), - 0, - "should return zero for a zero configured timeout so the launch is skipped" - ); + impl CanonicalTimeoutBackend { + fn new(canonical_ms: u32, calls: Arc>>) -> Self { + Self { + canonical_ms, + calls, + } + } + } + + impl PlatformBackend for CanonicalTimeoutBackend { + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("stub-backend".to_owned()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("stub-backend".to_owned()) + } + + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + self.calls + .lock() + .expect("should lock canonicalize calls") + .push((remaining_ms, configured_ms)); + self.canonical_ms + } } #[test] - fn sub_quantum_configured_timeout_still_launches_provider() { + fn parallel_launch_applies_canonical_timeout_to_name_and_request() { futures::executor::block_on(async { - // A provider whose configured timeout is below one quantum must - // still launch with its exact configured value: the constant is - // name-stable on its own, so only budget-derived values are - // quantized. + // The orchestrator must hand the platform-canonicalized value to + // BOTH `backend_name` (which derives the correlation key) and + // `request_bids` (via `context.timeout_ms`). Recording them + // separately and asserting exact equality catches a regression that + // predicts one bucket but registers another — which would drop the + // response into the "unknown backend" branch. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(750, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], @@ -2137,8 +2132,9 @@ mod tests { orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( "bidder", "bidder-backend", - 100, - Arc::clone(&observed), + 1000, + Arc::clone(&predicted), + Arc::clone(&requested), ))); let request = create_test_auction_request(); @@ -2161,49 +2157,62 @@ mod tests { .await .expect("should complete auction"); - let observed = observed.lock().expect("should lock observed timeouts"); + let predicted = predicted.lock().expect("should lock predicted"); + let requested = requested.lock().expect("should lock requested"); + assert_eq!( + *predicted, + vec![750], + "backend_name should receive the canonicalized value" + ); + assert_eq!( + *requested, + vec![750], + "request_bids should receive the same canonicalized value" + ); + assert_eq!( + *predicted, *requested, + "predicted and registered transport timeouts must be identical" + ); + + let calls = calls.lock().expect("should lock calls"); + assert_eq!(calls.len(), 1, "should canonicalize once for the launch"); + let (remaining_ms, configured_ms) = calls[0]; + assert_eq!( + configured_ms, 1000, + "should pass the provider's configured timeout as the configured bound" + ); assert!( - !observed.is_empty(), - "should launch the sub-quantum-configured provider" + remaining_ms > 0 && remaining_ms <= 2000, + "should pass the live remaining budget, got {remaining_ms}ms" ); - for timeout in observed.iter() { - assert_eq!( - *timeout, 100, - "should pass the configured 100ms timeout through unchanged" - ); - } }); } #[test] - fn parallel_path_quantizes_provider_transport_timeout() { + fn zero_canonical_timeout_skips_parallel_launch() { futures::executor::block_on(async { - // A 999ms budget must reach the provider as the 750ms quantum - // bucket — both in backend_name (which derives the Fastly backend - // name) and in context.timeout_ms (which configures the backend - // and payload deadlines) — so the backend name stays stable - // across requests with slightly different remaining budgets. + // A platform that canonicalizes to zero signals "budget exhausted"; + // the orchestrator must skip the launch. With the only provider + // skipped, no requests launch and the auction errors. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(0, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], - timeout_ms: 999, + timeout_ms: 2000, mediator: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( "bidder", "bidder-backend", - 2000, - Arc::clone(&observed), ))); let request = create_test_auction_request(); @@ -2216,60 +2225,55 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 999, + timeout_ms: 2000, provider_responses: None, services, }; - orchestrator - .run_auction(&request, &context) - .await - .expect("should complete auction"); - - let observed = observed.lock().expect("should lock observed timeouts"); + let result = orchestrator.run_auction(&request, &context).await; assert!( - !observed.is_empty(), - "should record provider transport timeouts" + result.is_err(), + "should error when the only provider is skipped for an exhausted budget" ); - for timeout in observed.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 - && *timeout > 0 - && *timeout <= 750, - "should floor the 999ms budget to a quantum bucket at or below 750ms, got {timeout}ms" - ); - } }); } #[test] - fn sub_quantum_budget_launches_with_exact_remaining_timeout() { + fn synchronous_mediation_applies_canonical_timeout_to_mediator() { futures::executor::block_on(async { - // A configured auction budget below one quantum must still launch - // providers with the exact remaining budget — rounding it to zero - // would hard-fail every auction for publishers with sub-250ms - // budgets. + // The mediator runs after the bidding phase and has no select-loop + // backstop; it must still receive the platform-canonicalized value + // for both prediction and request. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(500, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], - timeout_ms: 200, - mediator: None, + mediator: Some("mediator".to_string()), + timeout_ms: 2000, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( "bidder", "bidder-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "mediator", + "mediator-backend", 2000, - Arc::clone(&observed), + Arc::clone(&predicted), + Arc::clone(&requested), ))); let request = create_test_auction_request(); @@ -2282,67 +2286,76 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 200, + timeout_ms: 2000, provider_responses: None, services, }; - let result = orchestrator + orchestrator .run_auction(&request, &context) .await - .expect("should complete auction with a sub-quantum budget"); + .expect("should complete mediated auction"); - assert_eq!( - result.provider_responses.len(), - 1, - "should launch the provider despite the sub-quantum budget" - ); - let observed = observed.lock().expect("should lock observed timeouts"); + let predicted = predicted.lock().expect("should lock predicted"); + let requested = requested.lock().expect("should lock requested"); + // The orchestrator hands the mediator its budget through + // `context.timeout_ms` and calls `request_bids` directly; it does not + // call the mediator's `backend_name` (the mediator self-registers its + // backend), so only the request side is observed here. assert!( - !observed.is_empty(), - "should record provider transport timeouts" + predicted.is_empty(), + "orchestrator should not separately predict a backend name for the mediator" + ); + assert_eq!( + *requested, + vec![500], + "mediator request should use the canonical value" ); - for timeout in observed.iter() { - assert!( - *timeout > 0 && *timeout <= 200, - "should pass the exact sub-quantum remaining budget through, got {timeout}ms" - ); - } }); } #[test] - fn synchronous_mediation_quantizes_mediator_timeout() { + fn dispatched_collect_applies_canonical_timeout_to_both_paths() { futures::executor::block_on(async { - // The mediator has no select-loop deadline backstop, so its - // transport timeout must be quantized by rounding down: a - // quantum-aligned value no larger than the remaining budget. + // Same wiring invariant on the split dispatch/collect path used by + // publisher page rendering: the dispatched bidder and the collected + // mediator both receive the canonicalized value for prediction and + // request. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); // bidder send_async stub.push_response(200, b"{}".to_vec()); // mediator send_async - let services = build_services_with_http_client(stub); + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(500, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); + let bidder_predicted = Arc::new(Mutex::new(Vec::new())); + let bidder_requested = Arc::new(Mutex::new(Vec::new())); + let mediator_predicted = Arc::new(Mutex::new(Vec::new())); + let mediator_requested = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], mediator: Some("mediator".to_string()), - timeout_ms: 999, + timeout_ms: 2000, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( "bidder", "bidder-backend", + 2000, + Arc::clone(&bidder_predicted), + Arc::clone(&bidder_requested), ))); orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( "mediator", "mediator-backend", 2000, - Arc::clone(&observed), + Arc::clone(&mediator_predicted), + Arc::clone(&mediator_requested), ))); let request = create_test_auction_request(); @@ -2355,65 +2368,168 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 999, + timeout_ms: 2000, provider_responses: None, services, }; + let dispatched = match orchestrator.dispatch_auction(&request, &context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + _ => panic!("should dispatch the bidder request"), + }; orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + + let bidder_predicted = bidder_predicted + .lock() + .expect("should lock bidder predicted"); + let bidder_requested = bidder_requested + .lock() + .expect("should lock bidder requested"); + assert_eq!( + *bidder_predicted, + vec![500], + "dispatched bidder name should use canonical value" + ); + assert_eq!( + *bidder_requested, + vec![500], + "dispatched bidder request should use canonical value" + ); + assert_eq!( + *bidder_predicted, *bidder_requested, + "dispatched bidder predicted and registered timeouts must be identical" + ); + + let mediator_predicted = mediator_predicted + .lock() + .expect("should lock mediator predicted"); + let mediator_requested = mediator_requested + .lock() + .expect("should lock mediator requested"); + // As on the synchronous path, the orchestrator calls the mediator's + // `request_bids` directly without predicting a backend name for it. + assert!( + mediator_predicted.is_empty(), + "orchestrator should not separately predict a backend name for the mediator" + ); + assert_eq!( + *mediator_requested, + vec![500], + "mediator request should use the canonical value" + ); + }); + } + + #[test] + fn parallel_duplicate_backend_name_fails_second_provider_attributably() { + futures::executor::block_on(async { + // Two providers that canonicalize to the SAME backend name (e.g. two + // auction providers behind one gateway origin). The correlation map + // keys on backend name, so the second must not silently overwrite + // the first — it must fail attributably so no bid is misparsed or + // lost. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // provider-a send_async + stub.push_response(200, b"{}".to_vec()); // provider-b send_async (dropped after guard) + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + let result = orchestrator .run_auction(&request, &context) .await - .expect("should complete mediated auction"); + .expect("should complete auction despite the name collision"); - let observed = observed.lock().expect("should lock observed timeouts"); - assert!(!observed.is_empty(), "should run the mediator"); - for timeout in observed.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, - "mediator timeout {timeout}ms should be quantum-aligned" - ); - assert!( - *timeout > 0 && *timeout <= 750, - "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" - ); - } + assert_eq!( + result.provider_responses.len(), + 2, + "should account for both providers" + ); + let provider_a = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!( + provider_a.status, + BidStatus::Success, + "the first provider on the shared name should launch and succeed" + ); + assert_eq!( + provider_b.status, + BidStatus::Error, + "the second provider on the shared name should fail attributably, not be dropped" + ); }); } #[test] - fn dispatched_collect_quantizes_mediator_timeout() { + fn dispatched_duplicate_backend_name_fails_second_provider_attributably() { futures::executor::block_on(async { - // Same invariant as the synchronous path, on the split - // dispatch/collect path used by publisher page rendering. + // Same collision defense on the dispatch/collect path. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // bidder send_async - stub.push_response(200, b"{}".to_vec()); // mediator send_async + stub.push_response(200, b"{}".to_vec()); // provider-a send_async + stub.push_response(200, b"{}".to_vec()); // provider-b send_async (dropped after guard) let services = build_services_with_http_client(stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed_bidder = Arc::new(Mutex::new(Vec::new())); - let observed_mediator = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], - mediator: Some("mediator".to_string()), - timeout_ms: 999, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( - "bidder", - "bidder-backend", - 2000, - Arc::clone(&observed_bidder), + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", ))); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( - "mediator", - "mediator-backend", - 2000, - Arc::clone(&observed_mediator), + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", ))); let request = create_test_auction_request(); @@ -2426,47 +2542,29 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 999, + timeout_ms: 2000, provider_responses: None, services, }; let dispatched = match orchestrator.dispatch_auction(&request, &context).await { DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch the bidder request"), + _ => panic!("should dispatch the first provider despite the name collision"), }; - orchestrator + let result = orchestrator .collect_dispatched_auction(dispatched, services, &context) .await; - let observed_bidder = observed_bidder.lock().expect("should lock bidder timeouts"); - assert!( - !observed_bidder.is_empty(), - "should record dispatched bidder timeouts" + let provider_b = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!( + provider_b.status, + BidStatus::Error, + "the second provider on the shared name should fail attributably, not be dropped" ); - for timeout in observed_bidder.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 - && *timeout > 0 - && *timeout <= 750, - "dispatched bidder timeout should floor 999ms to a quantum bucket at or below 750ms, got {timeout}ms" - ); - } - - let observed_mediator = observed_mediator - .lock() - .expect("should lock mediator timeouts"); - assert!(!observed_mediator.is_empty(), "should run the mediator"); - for timeout in observed_mediator.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, - "mediator timeout {timeout}ms should be quantum-aligned" - ); - assert!( - *timeout > 0 && *timeout <= 750, - "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" - ); - } }); } diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index fa096d59d..833898b50 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -174,6 +174,7 @@ pub fn dispatch_pull_sync( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }) { Ok(name) => name, Err(err) => { diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 717ad46e8..eedcf7cd2 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -160,6 +160,7 @@ impl DataDomeIntegration { certificate_check: true, first_byte_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), between_bytes_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), + discriminator: None, }; services.backend().ensure(&spec).change_context(Self::error( diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 2052215f2..75b4693ff 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -153,6 +153,10 @@ fn integration_backend_spec( certificate_check, first_byte_timeout, between_bytes_timeout: first_byte_timeout, + // Distinguish this integration's backend from any other provider that + // targets the same origin, so auction response correlation by backend + // name cannot cross providers. + discriminator: Some(integration.to_string()), }) } diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ee7201fb8..d6ffee23b 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -649,6 +649,33 @@ pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { .build() } +/// Build a [`RuntimeServices`] with a caller-supplied [`PlatformBackend`] and +/// HTTP client. +/// +/// Lets auction tests inject a backend whose +/// [`PlatformBackend::canonicalize_transport_timeout_ms`] returns a controlled +/// value, so the orchestrator's transport-timeout wiring can be asserted +/// deterministically without depending on wall-clock timing. +pub(crate) fn build_services_with_backend_and_http_client( + backend: Arc, + http_client: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(backend) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + ..ClientInfo::default() + }) + .build() +} + /// Build a [`RuntimeServices`] with a custom secret store, [`StubBackend`], and HTTP client. pub(crate) fn build_services_with_secret_and_http_client( secret_store: impl PlatformSecretStore + 'static, @@ -856,6 +883,7 @@ mod tests { certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }; let name = stub.ensure(&spec).expect("should return a backend name"); assert_eq!(name, "stub-backend", "should return fixed name"); diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index ecc886c9d..c6af0a307 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -110,6 +110,28 @@ pub trait PlatformBackend: Send + Sync { /// Returns [`PlatformError::Backend`] when the backend cannot be /// registered on the platform. fn ensure(&self, spec: &PlatformBackendSpec) -> Result>; + + /// Canonicalize a per-provider transport timeout for backend-name stability. + /// + /// `remaining_ms` is the wall-clock budget left in the auction and + /// `configured_ms` is the provider's own configured timeout. The returned + /// value is used both to derive the dynamic backend name and as the + /// provider's request deadline, so it must be identical for prediction and + /// registration of the same launch. + /// + /// Adapters that embed the transport timeout in the dynamic backend name + /// (Fastly) override this to round budget-derived values to a coarse + /// ladder, so per-request wall-clock jitter neither defeats cross-request + /// connection pooling nor accumulates registrations toward the per-service + /// dynamic backend limit. + /// + /// The default returns the exact budget-bound value + /// (`remaining_ms.min(configured_ms)`): adapters that neither register nor + /// enforce a backend-name transport timeout gain nothing from rounding and + /// must not shorten bidder deadlines for no benefit. + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + remaining_ms.min(configured_ms) + } } /// Synchronous, object-safe geo lookup. diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index a81c24105..a39a26430 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -143,6 +143,16 @@ pub struct PlatformBackendSpec { pub first_byte_timeout: Duration, /// Maximum time to wait between response body bytes. pub between_bytes_timeout: Duration, + /// Optional stable discriminator folded into the backend name. + /// + /// Two callers can target the same origin (scheme, host, port, TLS) with + /// the same transport timeout yet need distinct dynamic backends — for + /// example two auction providers behind one gateway host. Because the + /// auction orchestrator correlates responses back to providers by backend + /// name, a shared name would let one provider's response be parsed as + /// another's. Setting this to a per-provider/integration identifier keeps + /// their names distinct while remaining stable across requests. + pub discriminator: Option, } /// Cloneable container of platform services for a single request. diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 19c10a80d..d1a5cdc57 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1099,6 +1099,7 @@ pub async fn handle_asset_proxy_request( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }) .change_context(TrustedServerError::Proxy { message: "asset backend registration failed".to_string(), @@ -1289,6 +1290,7 @@ async fn proxy_with_redirects( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }) .change_context(TrustedServerError::Proxy { message: "backend registration failed".to_string(), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5d..7a2b9b437 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1368,6 +1368,7 @@ pub async fn handle_publisher_request( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT, + discriminator: None, }) .change_context(TrustedServerError::Proxy { message: "backend registration failed".to_string(), From 62a2dd054adf18f13e2157ce9a3df4e18101bf80 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 14:56:06 -0500 Subject: [PATCH 021/494] Add Prebid error diagnostics Prebid non-2xx responses were reduced to bare provider errors, making intermittent failures difficult to diagnose. Surface safe HTTP metadata and bounded debug details while correlating server logs with the auction ID. --- .../src/integrations/prebid.rs | 551 ++++++++++++++---- docs/guide/integrations/prebid.md | 28 +- 2 files changed, 450 insertions(+), 129 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 22c972f5c..2e7655382 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -62,20 +62,134 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -#[cfg(test)] +const PREBID_ERROR_TYPE_UPSTREAM_HTTP: &str = "upstream_http"; +const PREBID_PUBLIC_ERROR_MESSAGE_CHARS: usize = 500; const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; - -#[cfg(test)] const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; +const PREBID_ERROR_JSON_MAX_DEPTH: usize = 6; +const PREBID_ERROR_JSON_KEYS: [&str; 6] = + ["message", "error", "errors", "detail", "title", "reason"]; + +#[derive(Debug, Eq, PartialEq)] +struct BoundedPrebidErrorText { + text: String, + truncated: bool, +} -#[cfg(test)] -fn prebid_body_preview(body: &[u8]) -> String { +fn bounded_prebid_error_text(value: &str, max_chars: usize) -> Option { + let mut text = String::new(); + let mut char_count = 0; + let mut pending_space = false; + let mut truncated = false; + + for character in value.chars() { + if character.is_whitespace() || character.is_control() { + pending_space = !text.is_empty(); + continue; + } + + if pending_space { + if char_count == max_chars { + truncated = true; + break; + } + text.push(' '); + char_count += 1; + pending_space = false; + } + + if char_count == max_chars { + truncated = true; + break; + } + text.push(character); + char_count += 1; + } + + (!text.is_empty()).then_some(BoundedPrebidErrorText { text, truncated }) +} + +fn prebid_body_preview(body: &[u8]) -> Option { let bounded_body = &body[..body.len().min(PREBID_ERROR_BODY_PREVIEW_BYTES)]; + let mut preview = bounded_prebid_error_text( + &String::from_utf8_lossy(bounded_body), + PREBID_ERROR_BODY_PREVIEW_CHARS, + )?; + preview.truncated |= body.len() > bounded_body.len(); + Some(preview) +} - String::from_utf8_lossy(bounded_body) - .chars() - .take(PREBID_ERROR_BODY_PREVIEW_CHARS) - .collect() +fn nested_prebid_json_error_message( + value: &Json, + depth: usize, + allow_direct_string: bool, +) -> Option<&str> { + if depth > PREBID_ERROR_JSON_MAX_DEPTH { + return None; + } + + match value { + Json::String(message) if allow_direct_string => { + (!message.trim().is_empty()).then_some(message.as_str()) + } + Json::Array(values) => values.iter().find_map(|value| { + nested_prebid_json_error_message(value, depth + 1, allow_direct_string) + }), + Json::Object(values) => PREBID_ERROR_JSON_KEYS + .iter() + .find_map(|key| { + values + .get(*key) + .and_then(|value| nested_prebid_json_error_message(value, depth + 1, true)) + }) + .or_else(|| { + values + .values() + .find_map(|value| nested_prebid_json_error_message(value, depth + 1, false)) + }), + _ => None, + } +} + +fn prebid_json_error_message(value: &Json) -> Option<&str> { + let Json::Object(values) = value else { + return None; + }; + + PREBID_ERROR_JSON_KEYS.iter().find_map(|key| { + values + .get(*key) + .and_then(|value| nested_prebid_json_error_message(value, 0, true)) + }) +} + +fn is_plain_text_content_type(content_type: Option<&str>) -> bool { + content_type.is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain")) + }) +} + +fn extract_prebid_error_message( + body: &[u8], + content_type: Option<&str>, +) -> Option { + let candidate = match serde_json::from_slice::(body) { + Ok(value) => prebid_json_error_message(&value)?.to_owned(), + Err(_) if is_plain_text_content_type(content_type) => { + std::str::from_utf8(body).ok()?.to_owned() + } + Err(_) => return None, + }; + + // Do not expose an HTML error page even if an intermediary labels it as text/plain. + if candidate.trim_start().starts_with('<') { + return None; + } + + bounded_prebid_error_text(&candidate, PREBID_PUBLIC_ERROR_MESSAGE_CHARS) } /// CCPA/US-privacy string sent when the `Sec-GPC` header signals opt-out. @@ -1845,6 +1959,113 @@ impl PrebidAuctionProvider { } } + async fn parse_response_inner( + &self, + response: PlatformResponse, + response_time_ms: u64, + auction_id: Option<&str>, + ) -> Result> { + let response = response.response; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + // Parse response — collect_response_bounded caps memory from misbehaving providers. + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "prebid", + ) + .await + .change_context(TrustedServerError::Prebid { + message: "Failed to read Prebid response body".to_string(), + })?; + + if !status.is_success() { + let auction_id = auction_id.unwrap_or(""); + log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); + + if self.config.debug { + match prebid_body_preview(&body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); + } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), + } + } + + let status_code = status.as_u16(); + let mut auction_response = + AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) + .with_metadata( + "error_type", + serde_json::json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP), + ) + .with_metadata("http_status", serde_json::json!(status_code)) + .with_metadata( + "message", + serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), + ); + + if self.config.debug { + if let Some(message) = + extract_prebid_error_message(&body_bytes, content_type.as_deref()) + { + auction_response.metadata.insert( + "upstream_message".to_string(), + serde_json::json!(message.text), + ); + auction_response.metadata.insert( + "upstream_message_truncated".to_string(), + serde_json::json!(message.truncated), + ); + } + } + + return Ok(auction_response); + } + + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { + message: "Failed to parse Prebid response".to_string(), + })?; + + // Log the full response body when debug is enabled to surface + // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. + if self.config.debug && log::log_enabled!(log::Level::Trace) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), + Err(e) => { + log::warn!("Prebid: failed to serialize response for logging: {e}"); + } + } + } + + let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); + self.enrich_response_metadata(&response_json, &mut auction_response); + + log::info!( + "Prebid returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) + } + fn should_suppress_bid_notifications(&self, bidder: &str) -> bool { self.config.suppress_nurl || self @@ -2108,68 +2329,19 @@ impl AuctionProvider for PrebidAuctionProvider { response: PlatformResponse, response_time_ms: u64, ) -> Result> { - let response = response.response; - let status = response.status(); - - // Parse response — collect_response_bounded caps memory from misbehaving providers. - let body_bytes = collect_response_bounded( - response.into_body(), - UPSTREAM_RTB_MAX_RESPONSE_BYTES, - "prebid", - ) - .await - .change_context(TrustedServerError::Prebid { - message: "Failed to read Prebid response body".to_string(), - })?; - - if !status.is_success() { - let body_preview = String::from_utf8_lossy(&body_bytes); - // SECURITY: the PBS response body is upstream-controlled and may leak - // internal detail (hostnames, stack traces, auth hints). Per the - // invariant documented in `auction/orchestrator.rs`, it MUST NOT reach - // the public `/auction` response, which happens if it lands in - // `AuctionResponse.metadata` (cloned verbatim into - // `ext.orchestrator.provider_details[].metadata`). Log the snippet - // server-side and surface only the numeric HTTP status — enough for an - // operator to tell an error from a no-bid without publishing the body. - log::warn!( - "Prebid returned non-success status {status}: {}", - &body_preview[..body_preview.floor_char_boundary(512)] - ); - return Ok(AuctionResponse::error("prebid", response_time_ms) - .with_metadata( - "error_type", - serde_json::json!(crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS), - ) - .with_metadata("status", serde_json::json!(status.as_u16()))); - } - - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { - message: "Failed to parse Prebid response".to_string(), - })?; - - // Log the full response body when debug is enabled to surface - // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. - if self.config.debug && log::log_enabled!(log::Level::Trace) { - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), - Err(e) => { - log::warn!("Prebid: failed to serialize response for logging: {e}"); - } - } - } - - let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); - self.enrich_response_metadata(&response_json, &mut auction_response); - - log::info!( - "Prebid returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); + self.parse_response_inner(response, response_time_ms, None) + .await + } - Ok(auction_response) + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + self.parse_response_inner(response, response_time_ms, Some(request.id.as_str())) + .await } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -2243,8 +2415,7 @@ mod tests { use super::*; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, - UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; use crate::consent::{ConsentContext, ConsentSource}; @@ -2352,50 +2523,6 @@ mod tests { ); } - #[test] - fn parse_response_attaches_status_metadata_without_leaking_body_on_http_error() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(403) - .body(EdgeBody::from( - br#"{"error":"upstream-secret-detail"}"#.to_vec(), - )) - .expect("should build test response"), - ); - - let result = futures::executor::block_on(provider.parse_response(response, 643)) - .expect("should return Ok(error response) for non-success status"); - - assert_eq!( - result.status, - BidStatus::Error, - "non-success HTTP status should map to an error response" - ); - assert_eq!( - result.metadata["error_type"], - json!("http_status"), - "should tag the error path so telemetry buckets it as an http status error" - ); - assert_eq!( - result.metadata["status"], - json!(403), - "should surface the upstream HTTP status code" - ); - // SECURITY: the upstream response body must never reach the public - // /auction response via AuctionResponse.metadata. - assert!( - !result.metadata.contains_key("body"), - "upstream response body must not be surfaced on the response metadata" - ); - assert!( - !result.metadata.values().any(|v| v - .as_str() - .is_some_and(|s| s.contains("upstream-secret-detail"))), - "no metadata value may contain the upstream body" - ); - } - fn test_sri(algorithm: &str, digest: &[u8]) -> String { format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) } @@ -2430,6 +2557,23 @@ mod tests { .expect("should parse response body as utf-8") } + fn prebid_platform_response( + status: StatusCode, + content_type: Option<&str>, + body: impl Into>, + ) -> PlatformResponse { + let mut builder = http::Response::builder().status(status); + if let Some(content_type) = content_type { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + + PlatformResponse::new( + builder + .body(EdgeBody::from(body.into())) + .expect("should build Prebid platform response"), + ) + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -4835,27 +4979,42 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + fn bounded_prebid_error_text_normalizes_control_characters_and_whitespace() { + let message = bounded_prebid_error_text("\n invalid\trequest\0 payload \r\n", 100) + .expect("should extract bounded text"); + + assert_eq!( + message.text, "invalid request payload", + "should make upstream text safe for one-line responses and logs" + ); + assert!(!message.truncated, "should retain the complete message"); + } + #[test] fn prebid_body_preview_truncates_to_character_limit() { let body = "x".repeat(PREBID_ERROR_BODY_PREVIEW_CHARS + 100); - let preview = prebid_body_preview(body.as_bytes()); + let preview = prebid_body_preview(body.as_bytes()).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, "should cap the upstream body preview" ); + assert!(preview.truncated, "should report body preview truncation"); } #[test] fn prebid_body_preview_handles_non_utf8_lossily() { - let preview = prebid_body_preview(&[b'o', b'k', 0xff, b'!']); + let preview = + prebid_body_preview(&[b'o', b'k', 0xff, b'!']).expect("should build body preview"); assert_eq!( - preview, "ok\u{fffd}!", + preview.text, "ok\u{fffd}!", "should replace invalid UTF-8 bytes without panicking" ); + assert!(!preview.truncated, "should retain the complete preview"); } #[test] @@ -4863,17 +5022,18 @@ external_bundle_sri = "sha384-AAAA" let mut body = vec![b'x'; PREBID_ERROR_BODY_PREVIEW_BYTES]; body.extend_from_slice(&[0xff, b't', b'a', b'i', b'l']); - let preview = prebid_body_preview(&body); + let preview = prebid_body_preview(&body).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, - "should keep the public preview capped" + "should keep the log preview capped" ); assert!( - !preview.contains('\u{fffd}') && !preview.contains("tail"), + !preview.text.contains('\u{fffd}') && !preview.text.contains("tail"), "should not process bytes beyond the bounded preview slice" ); + assert!(preview.truncated, "should report bounded-slice truncation"); } #[test] @@ -4882,17 +5042,152 @@ external_bundle_sri = "sha384-AAAA" body.extend_from_slice("\u{2603}".as_bytes()); body.extend_from_slice(b"tail"); - let preview = prebid_body_preview(&body); + let preview = prebid_body_preview(&body).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, - "should keep the public preview capped" + "should keep the log preview capped" ); assert!( - !preview.contains("tail"), + !preview.text.contains("tail"), "should not include bytes beyond the bounded preview slice" ); + assert!(preview.truncated, "should report partial-body truncation"); + } + + #[test] + fn extract_prebid_error_message_reads_nested_json_message() { + let body = br#"{ + "errors": { + "exampleBidder": [{"code": 1, "message": " invalid\nrequest "}] + } + }"#; + + let message = extract_prebid_error_message(body, Some("application/json")) + .expect("should extract nested JSON error message"); + + assert_eq!(message.text, "invalid request"); + assert!(!message.truncated, "should retain the complete message"); + } + + #[test] + fn extract_prebid_error_message_reads_plain_text() { + let message = extract_prebid_error_message( + b" request rejected\r\nby Prebid Server ", + Some("Text/Plain; charset=utf-8"), + ) + .expect("should extract plain-text error message"); + + assert_eq!(message.text, "request rejected by Prebid Server"); + assert!(!message.truncated, "should retain the complete message"); + } + + #[test] + fn extract_prebid_error_message_rejects_html_and_unknown_json_fields() { + assert!( + extract_prebid_error_message( + b"internal proxy error", + Some("text/plain"), + ) + .is_none(), + "should not expose HTML error pages" + ); + + for body in [ + br#"{"resolvedrequest":{"account":"internal"}}"#.as_slice(), + br#"{"errors":{"resolvedrequest":{"account":"internal"}}}"#.as_slice(), + br#""internal""#.as_slice(), + br#"["internal"]"#.as_slice(), + ] { + assert!( + extract_prebid_error_message(body, Some("application/json")).is_none(), + "should only expose strings associated with allowlisted JSON error fields" + ); + } + } + + #[test] + fn extract_prebid_error_message_truncates_public_message() { + let body = serde_json::to_vec(&json!({ + "message": "x".repeat(PREBID_PUBLIC_ERROR_MESSAGE_CHARS + 100), + })) + .expect("should serialize test error response"); + + let message = extract_prebid_error_message(&body, Some("application/json")) + .expect("should extract JSON error message"); + + assert_eq!( + message.text.chars().count(), + PREBID_PUBLIC_ERROR_MESSAGE_CHARS, + "should cap the browser-visible upstream message" + ); + assert!(message.truncated, "should report public message truncation"); + } + + #[test] + fn non_success_prebid_response_always_includes_safe_http_metadata() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = prebid_platform_response( + StatusCode::BAD_REQUEST, + Some("application/json"), + br#"{"message":"request details should remain hidden"}"#.to_vec(), + ); + + let auction_response = futures::executor::block_on(provider.parse_response(response, 42)) + .expect("should convert upstream HTTP failure to auction response"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::Error + ); + assert_eq!( + auction_response.metadata["error_type"], + json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP) + ); + assert_eq!(auction_response.metadata["http_status"], json!(400)); + assert_eq!( + auction_response.metadata["message"], + json!("Prebid Server returned HTTP 400") + ); + assert!( + !auction_response.metadata.contains_key("upstream_message"), + "should hide upstream text when Prebid debug is disabled" + ); + } + + #[test] + fn debug_non_success_prebid_response_includes_bounded_upstream_message() { + let mut config = base_config(); + config.debug = true; + let provider = PrebidAuctionProvider::new(config); + let response = prebid_platform_response( + StatusCode::UNPROCESSABLE_ENTITY, + Some("application/json; charset=utf-8"), + br#"{"error":{"message":"imp[0] has no valid bidders"}}"#.to_vec(), + ); + let settings = make_settings(); + let http_request = build_test_request(); + let context = create_test_auction_context(&settings, &http_request); + let auction_request = create_test_auction_request(); + + let auction_response = futures::executor::block_on(provider.parse_response_with_context( + response, + 66, + &auction_request, + &context, + )) + .expect("should convert upstream HTTP failure to debug auction response"); + + assert_eq!(auction_response.metadata["http_status"], json!(422)); + assert_eq!( + auction_response.metadata["upstream_message"], + json!("imp[0] has no valid bidders") + ); + assert_eq!( + auction_response.metadata["upstream_message_truncated"], + json!(false) + ); } fn make_auction_request(slots: Vec) -> AuctionRequest { diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index c1e85a553..b8340cfb5 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -132,8 +132,34 @@ The Prebid provider extracts metadata from the Prebid Server response and attach | `debug` | `ext.debug` | Prebid Server debug payload (httpcalls, resolvedrequest) | | `bidstatus` | `ext.prebid.bidstatus` | Per-bid status from every invited bidder | +### Upstream HTTP errors + +When Prebid Server returns a non-2xx status, the provider detail always includes a safe error classification, HTTP status, and generic message: + +```json +{ + "error_type": "upstream_http", + "http_status": 400, + "message": "Prebid Server returned HTTP 400" +} +``` + +With `debug = true`, Trusted Server also extracts the first error message from allowlisted JSON fields (`message`, `error`, `errors`, `detail`, `title`, or `reason`) or a plain-text response. The message is normalized to one line and limited to 500 characters: + +```json +{ + "error_type": "upstream_http", + "http_status": 400, + "message": "Prebid Server returned HTTP 400", + "upstream_message": "Invalid request: imp[0] has no valid bidders", + "upstream_message_truncated": false +} +``` + +HTML error pages and unrecognized JSON payloads are not exposed. Debug mode also writes a bounded error-body preview to `tslog`, correlated with the auction ID. + ::: warning -Enabling `debug` increases response sizes and adds overhead. Use it in development or when diagnosing auction issues — not in production. +Enabling `debug` increases response sizes and adds overhead. It can also expose bounded upstream diagnostics to `/auction` callers and logs. Use it temporarily when diagnosing auction issues, not as a permanent production setting. ::: ### Test mode vs. debug From ef619539659d9126c11d6f0b22892af91c9b1163 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 23:20:24 +0530 Subject: [PATCH 022/494] Suppress fabricated empty Prebid bidder params A configured bidder with no inline params and no matching override expanded to `"bidder": {}`, which PBS rejects. After applying overrides, drop fabricated empty bidders, preserve an explicitly supplied empty object so genuine misconfiguration stays visible, and fall back to the stored-request path when no eligible bidders remain. --- .../src/integrations/prebid.rs | 179 ++++++++++++++++-- 1 file changed, 163 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a85..68d718a16 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -1424,10 +1424,22 @@ impl PrebidAuctionProvider { // Only pass through keys that are known PBS bidders — skip provider-specific // keys like "aps" which belong to their own separate auction provider. let mut bidder: HashMap = HashMap::new(); + // Bidders the publisher explicitly supplied — including an + // explicit empty `{}`. A configured bidder that exists only + // because `expand_trusted_server_bidders` fabricated an empty + // params object is NOT explicit and must not ship as + // `"bidder": {}` (which PBS rejects). + let mut explicit_bidders: HashSet = HashSet::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { + if let Some(per_bidder) = + params.get(BIDDER_PARAMS_KEY).and_then(Json::as_object) + { + explicit_bidders.extend(per_bidder.keys().cloned()); + } bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); } else if self.config.bidders.iter().any(|b| b == name) { + explicit_bidders.insert(name.clone()); bidder.insert(name.clone(), params.clone()); } else if name != "aps" { // `aps` is intentionally handled by its own provider. Any @@ -1443,16 +1455,32 @@ impl PrebidAuctionProvider { } } - // When no inline PBS bidder params exist (e.g. creative-opportunity slots - // whose PBS params live in stored requests), tell PBS to resolve bidder - // config from the stored request keyed by this slot ID. + // Apply canonical and compatibility-derived rules in normalized + // order. An override rule can populate a bidder that arrived with + // empty params, promoting a fabricated empty into a valid bidder. + for (name, params) in &mut bidder { + self.bid_param_override_engine + .apply(BidParamOverrideFacts { bidder: name, zone }, params); + } + + // Drop bidders that are still an empty object after overrides and + // were not explicitly supplied. Shipping `"bidder": {}` makes PBS + // reject the imp; an explicit empty object is preserved so genuine + // publisher misconfiguration stays visible. + bidder.retain(|name, params| { + let is_empty_object = params.as_object().is_some_and(serde_json::Map::is_empty); + !is_empty_object || explicit_bidders.contains(name) + }); + + // When no eligible PBS bidder params remain (e.g. creative-opportunity + // slots whose PBS params live in stored requests, or a slot whose + // configured bidders all resolved to fabricated empties), tell PBS to + // resolve bidder config from the stored request keyed by this slot ID. // - // This cannot fire for the client /auction path: the JS adapter - // injects a `trustedServer` entry into every ad unit, so `bidder` - // is only empty for server-side creative-opportunity slots with - // no inline provider params (or when `config.bidders` is empty, - // where PBS previously received an empty bidder map and returned - // no bids — a stored-request miss is the same no-bid outcome). + // This cannot fire for a client /auction slot that carries real + // inline params: the JS adapter injects a `trustedServer` entry, and + // any bidder with params survives the drop above. It falls back only + // when nothing eligible remains — the same no-bid outcome as before. let storedrequest = if bidder.is_empty() { Some(ImpStoredRequest { id: slot.id.clone(), @@ -1461,12 +1489,6 @@ impl PrebidAuctionProvider { None }; - // Apply canonical and compatibility-derived rules in normalized order. - for (name, params) in &mut bidder { - self.bid_param_override_engine - .apply(BidParamOverrideFacts { bidder: name, zone }, params); - } - Some(Imp { id: Some(slot.id.clone()), banner: Some(Banner { @@ -5950,6 +5972,131 @@ set = { placementId = "explicit_header" } ); } + #[test] + fn to_openrtb_drops_fabricated_empty_bidder_params() { + // config.bidders lists three, but the slot supplies inline params only + // for kargo. Without a matching override, triplelift and criteo would + // expand to empty `{}` objects — invalid bidder entries PBS rejects. + // They must be dropped; the valid kargo bidder must still ship. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift", "criteo"] +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "kn1" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"]["placementId"], "kn1", + "should keep the valid inline bidder" + ); + assert!( + !params.contains_key("triplelift"), + "should drop a fabricated empty bidder with no inline params or override" + ); + assert!( + !params.contains_key("criteo"), + "should drop a fabricated empty bidder with no inline params or override" + ); + } + + #[test] + fn to_openrtb_preserves_an_explicitly_empty_bidder() { + // A publisher-supplied empty `{}` is a real (if misconfigured) signal and + // must survive so the misconfiguration stays visible — unlike a fabricated + // empty, which is dropped. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({ "kargo": {} }), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"], + json!({}), + "should preserve an explicitly supplied empty bidder object" + ); + } + + #[test] + fn to_openrtb_keeps_a_fabricated_bidder_that_an_override_populates() { + // criteo has no inline params (fabricated empty), but an override rule + // fills it — so it is valid and must ship, not be dropped. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["criteo"] + +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({}), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["criteo"]["networkId"], 99999, + "override should populate the fabricated empty bidder, keeping it" + ); + } + + #[test] + fn to_openrtb_falls_back_to_stored_request_when_all_bidders_are_fabricated_empty() { + // config.bidders present, but the slot supplies no inline params and no + // override matches — every configured bidder resolves to a fabricated + // empty and is dropped, leaving PBS to resolve via the stored request. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift"] +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({}), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should drop all fabricated empty bidders" + ); + assert_eq!( + prebid["storedrequest"]["id"], "ad-header-0", + "should fall back to stored request when no eligible bidders remain" + ); + } + #[test] fn to_openrtb_skips_aps_key_from_slot_bidders_in_pbs_request() { let slot = make_slot( From 0acac4b207f26697084c8ca56368615136625911 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 14:07:32 -0500 Subject: [PATCH 023/494] Preserve Prebid ad units across GPT refreshes --- .../lib/src/integrations/prebid/index.ts | 239 +++++++- .../test/integrations/prebid/index.test.ts | 509 ++++++++++++++++++ 2 files changed, 731 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index be839d8fc..65932d5ba 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -228,6 +228,17 @@ type TrustedServerAdUnit = { mediaTypes?: { banner?: TrustedServerBanner }; bids?: TrustedServerBid[]; }; +type ClientSideBidSnapshot = { bidder: string; params: Record }; +type PublisherAdUnitSnapshot = { + bidderParams: Record>; + clientSideBids: ClientSideBidSnapshot[]; + zone?: string; +}; +type PublisherDeliveryContext = { remainingCodes: Set }; + +let publisherAdUnitSnapshots = new Map(); +let syntheticRefreshAdUnits = new WeakSet(); +const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -363,6 +374,17 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { * code in order and return the first matching ad unit, so container-backed slots * still recover the publisher's configured params and bidders. */ +function findRefreshSnapshot( + candidateCodes: Array +): PublisherAdUnitSnapshot | undefined { + for (const code of candidateCodes) { + if (!code) continue; + const snapshot = publisherAdUnitSnapshots.get(code); + if (snapshot) return snapshot; + } + return undefined; +} + function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -375,6 +397,89 @@ function findRefreshAdUnit( return undefined; } +function copyParamValue(value: unknown, seen = new WeakMap()): unknown { + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing) return existing; + const copy: unknown[] = []; + seen.set(value, copy); + value.forEach((entry) => copy.push(copyParamValue(entry, seen))); + return copy; + } + + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + + const existing = seen.get(value); + if (existing) return existing; + const copy = Object.create(prototype) as Record; + seen.set(value, copy); + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(copy, key, { + value: copyParamValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return copy; + } + + return value; +} + +function copyParams(params: Record | undefined): Record { + return copyParamValue(params ?? {}) as Record; +} + +function foldedBidderParams( + bid: TrustedServerBid | undefined +): Record> { + const folded = (bid?.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< + string, + Record + >; + return Object.fromEntries( + Object.entries(folded).map(([bidder, params]) => [bidder, copyParams(params)]) + ); +} + +function capturePublisherAdUnitSnapshot( + unit: TrustedServerAdUnit, + clientSideBidders: Set +): PublisherAdUnitSnapshot | undefined { + if (typeof unit.code !== 'string' || unit.code.length === 0) return undefined; + + const rawBidderParams: Record> = {}; + const clientSideBids: ClientSideBidSnapshot[] = []; + let existingTsBid: TrustedServerBid | undefined; + + const bids = Array.isArray(unit.bids) ? unit.bids : []; + for (const bid of bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + existingTsBid ??= bid; + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + continue; + } + rawBidderParams[bid.bidder] = copyParams(bid.params); + } + + const bidderParams = + Object.keys(rawBidderParams).length > 0 ? rawBidderParams : foldedBidderParams(existingTsBid); + const zone = unit.mediaTypes?.banner?.name; + + return { + bidderParams, + clientSideBids, + ...(zone ? { zone } : {}), + }; +} + /** * Collect the configured client-side bidder entries for a refreshing slot. * @@ -389,6 +494,14 @@ function findRefreshAdUnit( function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return snapshot.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })); + } + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); if (clientSideBidders.size === 0) return []; @@ -398,7 +511,7 @@ function clientSideBidsForRefresh( const bids: Array<{ bidder: string; params: Record }> = []; for (const bid of match.bids) { if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: bid.params ?? {} }); + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); } } return bids; @@ -420,6 +533,13 @@ function clientSideBidsForRefresh( function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) + ); + } + const match = findRefreshAdUnit(candidateCodes); if (!match?.bids) return {}; @@ -456,6 +576,50 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + const index = activePublisherDeliveryContexts.lastIndexOf(context); + if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); +} + +function consumeBarePublisherDeliveryContext(): boolean { + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + if (context.remainingCodes.size === 0) continue; + context.remainingCodes.clear(); + return true; + } + return false; +} + +function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { + if (targetSlots.length === 0) return false; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + const coveredCodes: string[] = []; + let allCovered = true; + + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const coveredCode = candidates.find( + (code): code is string => !!code && context.remainingCodes.has(code) + ); + if (!coveredCode) { + allCovered = false; + break; + } + coveredCodes.push(coveredCode); + } + + if (!allCovered) continue; + coveredCodes.forEach((code) => context.remainingCodes.delete(code)); + return true; + } + + return false; +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -492,6 +656,10 @@ function collectAuctionEids(): AuctionEid[] | undefined { * 2. `config` argument — explicit overrides from the publisher's JS */ export function installPrebidNpm(config?: Partial): typeof pbjs { + publisherAdUnitSnapshots = new Map(); + syntheticRefreshAdUnits = new WeakSet(); + activePublisherDeliveryContexts.length = 0; + const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { endpoint: config?.endpoint, @@ -563,9 +731,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; + const isSyntheticRefresh = + adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); + const publisherAdUnitCodes = new Set(); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { + if (!syntheticRefreshAdUnits.has(unit)) { + const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); + if (snapshot && unit.code) { + publisherAdUnitSnapshots.set(unit.code, snapshot); + publisherAdUnitCodes.add(unit.code); + } + } + if (!Array.isArray(unit.bids)) { unit.bids = []; } @@ -649,8 +828,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); - if (typeof originalBidsBack === 'function') { - originalBidsBack.apply(this, args); + if (typeof originalBidsBack !== 'function') return; + if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { + originalBidsBack.apply(this, args as Parameters); + return; + } + + const context: PublisherDeliveryContext = { + remainingCodes: new Set(publisherAdUnitCodes), + }; + // Delivery attribution is intentionally synchronous and ends as soon as + // the publisher's original callback returns. + activePublisherDeliveryContexts.push(context); + try { + originalBidsBack.apply(this, args as Parameters); + } finally { + removePublisherDeliveryContext(context); } }; @@ -734,6 +927,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { const originalRefresh = pubads.refresh.bind(pubads); pubads.refresh = function (slots?: unknown[], opts?: unknown) { + // For bare refresh() calls (no slots arg), get all registered slots from GPT + // so we can auction the same concrete slot list and avoid stale targeting. + const targetSlots = ( + slots ?? + (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? + [] + ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + // One-shot bypass for adInit()'s internal refresh: that refresh delivers // freshly applied server-side targeting to GAM and must not be turned // into a client-side auction (which would clear the TS targeting). @@ -743,13 +944,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - // For bare refresh() calls (no slots arg), get all registered slots from GPT - // so we can auction the same concrete slot list and avoid stale targeting. - const targetSlots = ( - slots ?? - (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? - [] - ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + const isExplicitSlotList = slots !== undefined; + const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; + const isPublisherDeliveryRefresh = isExplicitSlotList + ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) + : consumeBarePublisherDeliveryContext(); + if (isPublisherDeliveryRefresh) { + return originalRefresh(slots, opts); + } if (!targetSlots.length) { return originalRefresh(slots, opts); @@ -759,8 +961,16 @@ export function installRefreshHandler(timeoutMs = 1500): void { const adUnits = targetSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); + const code = refreshSlotElementId(slot) ?? 'refresh-slot'; + // A TS-owned slot may be defined on `${div_id}-container`, so the GPT + // element id used as the synthetic refresh code can differ from the + // inner `div_id` the publisher keyed their ad unit by. Recover from both. + const candidateCodes = [code, injectedSlot?.div_id]; + const snapshot = findRefreshSnapshot(candidateCodes); const zone = - injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); + injectedSlot?.targeting?.[ZONE_KEY] ?? + firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? + snapshot?.zone; const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -768,12 +978,6 @@ export function installRefreshHandler(timeoutMs = 1500): void { DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), }; - - const code = refreshSlotElementId(slot) ?? 'refresh-slot'; - // A TS-owned slot may be defined on `${div_id}-container`, so the GPT - // element id used as the synthetic refresh code can differ from the - // inner `div_id` the publisher keyed their ad unit by. Recover from both. - const candidateCodes = [code, injectedSlot?.div_id]; const tsParams: Record = zone ? { [ZONE_KEY]: zone } : {}; // Carry the publisher's inline server-side (PBS) bidder params captured // on the initial ad unit so refresh/scroll auctions don't drop them. @@ -796,6 +1000,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { // unrelated GPT slots whose targeting this wrapper only cleared for // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); + adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); pbjs.requestBids({ adUnits, bidsBackHandler: () => { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c4..6435d9708 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -617,6 +617,15 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('normalizes a truthy non-array bids value without throwing', () => { + const pbjs = installPrebidNpm(); + const adUnits = [{ code: 'example-malformed-slot', bids: { malformed: true } }] as any[]; + + expect(() => pbjs.requestBids({ adUnits } as any)).not.toThrow(); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1352,6 +1361,506 @@ describe('prebid/installRefreshHandler', () => { }); }); +describe('prebid publisher snapshots and delivery refreshes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequestBids.mockReset(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + mockGetUserIdsAsEids.mockReset(); + mockGetUserIdsAsEids.mockReturnValue([]); + mockGetBidAdapter.mockReturnValue({}); + delete (mockPbjs as any).setTargetingForGPTAsync; + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + afterEach(() => { + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + function installGpt(slots: any[]) { + const originalRefresh = vi.fn(); + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => slots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + installRefreshHandler(640); + return { originalRefresh, pubads }; + } + + function refreshAdUnitFromLastRequest(): any { + const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; + return lastCall?.[0]?.adUnits?.[0]; + } + + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const runtimeInstance = 'example-runtime-instance'; + const code = `example-slot-${runtimeInstance}`; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const firstParams = { placement: 'first' }; + const effectiveParams = { placement: 'effective' }; + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { bidder: 'exampleServer', params: firstParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleServer', params: effectiveParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }, + ], + } as any); + effectiveParams.placement = 'changed-after-auction'; + + pubads.refresh([slot]); + + expect(mockPbjs.adUnits).toEqual([]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(refreshAdUnitFromLastRequest()).toEqual({ + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { exampleServer: { placement: 'effective' } }, + zone: 'example-zone', + }, + }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }); + }); + + it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-nested-params-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const serverParams = { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }; + const browserParams = { + groups: [{ values: ['original-value'] }], + }; + + pbjs.requestBids({ + adUnits: [ + { + code, + bids: [ + { bidder: 'exampleServer', params: serverParams }, + { bidder: 'exampleBrowser', params: browserParams }, + ], + }, + ], + } as any); + serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.sizes.push(999); + browserParams.groups[0].values[0] = 'changed-value'; + + pubads.refresh([slot]); + + const expectedBids = [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + exampleServer: { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }, + }, + }, + }, + { + bidder: 'exampleBrowser', + params: { groups: [{ values: ['original-value'] }] }, + }, + ]; + const firstRefreshBids = refreshAdUnitFromLastRequest().bids; + expect(firstRefreshBids).toEqual(expectedBids); + + firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + 'changed-refresh-rule'; + firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); + firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); + }); + + it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { + const code = 'example-dynamic-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + ], + } as any); + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'two' } }, + zone: 'example-zone-two', + }); + }); + + it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { + const slotOne = { + getSlotElementId: () => 'example-code-one', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-code-two', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const globalSlot = { + getSlotElementId: () => 'example-global-code', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code: 'example-code-one', + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + { + code: 'example-code-two', + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + mockPbjs.adUnits = [ + { + code: 'example-global-code', + bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], + }, + ]; + + pubads.refresh([slotOne]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'one' }, + }); + pubads.refresh([slotTwo]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'two' }, + }); + pubads.refresh([globalSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleFallback: { placement: 'global' }, + }); + }); + + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { + const slotOne = { + getSlotElementId: () => 'example-covered-one', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-covered-two-container', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + (window as any).tsjs = { + adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + }; + const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, + { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pubads.refresh([slotOne]); + pubads.refresh([slotTwo]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slotOne.clearTargeting).not.toHaveBeenCalled(); + expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); + }); + + it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh(), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + }); + + it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const unrelatedSlot = { + getSlotElementId: () => 'example-unrelated', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + pubads.refresh([unrelatedSlot]); + pubads.refresh([coveredSlot, unrelatedSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-covered', + 'example-unrelated', + ]); + expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + }); + + it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + const slot = { + getSlotElementId: () => 'example-deferred-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [ + { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([innerSlot]), + } as any); + pubads.refresh([outerSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); + }); + + it('cleans delivery context after a publisher callback throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-callback', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + expect(() => + pbjs.requestBids({ + adUnits: [ + { + code: 'example-throwing-callback', + bids: [{ bidder: 'exampleServer', params: {} }], + }, + ], + bidsBackHandler: () => { + throw new Error('example callback failure'); + }, + } as any) + ).toThrow('example callback failure'); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + }); + + it('completes an internal synthetic refresh once without recursion', () => { + const slot = { + getSlotElementId: () => 'example-independent-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); +}); + describe('prebid/client-side bidders', () => { beforeEach(() => { vi.clearAllMocks(); From c59a064e6ef27a822b1986df65135b64d63ef8d5 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:21:19 -0500 Subject: [PATCH 024/494] Handle deferred Prebid delivery refreshes --- .../lib/src/integrations/prebid/index.ts | 101 ++++++++-- .../test/integrations/prebid/index.test.ts | 188 +++++++++++++++++- 2 files changed, 259 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 65932d5ba..e01cdd879 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -45,6 +45,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; +const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -234,7 +235,12 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { remainingCodes: Set }; +type PublisherDeliveryContext = { + remainingCodes: Set; + retainForTargetedRefresh: boolean; + cleanupTimer?: ReturnType; +}; +type SetTargetingForGptAsync = (...args: unknown[]) => unknown; let publisherAdUnitSnapshots = new Map(); let syntheticRefreshAdUnits = new WeakSet(); @@ -577,15 +583,32 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + if (context.cleanupTimer !== undefined) { + clearTimeout(context.cleanupTimer); + context.cleanupTimer = undefined; + } const index = activePublisherDeliveryContexts.lastIndexOf(context); if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } +function targetingCoversPublisherDeliveryContext( + adUnitCodes: unknown, + context: PublisherDeliveryContext +): boolean { + if (adUnitCodes === undefined) return context.remainingCodes.size > 0; + const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; + return ( + Array.isArray(codes) && + codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) + ); +} + function consumeBarePublisherDeliveryContext(): boolean { for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { const context = activePublisherDeliveryContexts[index]; if (context.remainingCodes.size === 0) continue; context.remainingCodes.clear(); + removePublisherDeliveryContext(context); return true; } return false; @@ -594,30 +617,35 @@ function consumeBarePublisherDeliveryContext(): boolean { function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { if (targetSlots.length === 0) return false; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCodes: string[] = []; - let allCovered = true; - - for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + // Publishers may include GAM-only slots in the same explicit refresh that + // delivers a completed Prebid auction. Attribute the call to delivery when + // any slot is covered, while consuming only the covered codes so an + // unrelated-only refresh still follows the synthetic auction path. + const matches = new Map>(); + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; const coveredCode = candidates.find( (code): code is string => !!code && context.remainingCodes.has(code) ); - if (!coveredCode) { - allCovered = false; - break; - } - coveredCodes.push(coveredCode); + if (!coveredCode) continue; + + const contextMatches = matches.get(context) ?? new Set(); + contextMatches.add(coveredCode); + matches.set(context, contextMatches); + break; } + } - if (!allCovered) continue; + if (matches.size === 0) return false; + for (const [context, coveredCodes] of matches) { coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - return true; + if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); } - - return false; + return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -658,7 +686,7 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); syntheticRefreshAdUnits = new WeakSet(); - activePublisherDeliveryContexts.length = 0; + [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -836,14 +864,43 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const context: PublisherDeliveryContext = { remainingCodes: new Set(publisherAdUnitCodes), + retainForTargetedRefresh: false, + }; + const targetingPbjs = pbjs as unknown as { + setTargetingForGPTAsync?: SetTargetingForGptAsync; }; - // Delivery attribution is intentionally synchronous and ends as soon as - // the publisher's original callback returns. + const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; + let targetingWrapper: SetTargetingForGptAsync | undefined; + if (typeof originalSetTargeting === 'function') { + targetingWrapper = (...targetingArgs: unknown[]) => { + const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); + if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { + context.retainForTargetedRefresh = true; + } + return result; + }; + targetingPbjs.setTargetingForGPTAsync = targetingWrapper; + } + activePublisherDeliveryContexts.push(context); try { originalBidsBack.apply(this, args as Parameters); } finally { - removePublisherDeliveryContext(context); + if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { + targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; + } + if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { + // Some publisher wrappers set targeting in bidsBackHandler, return, + // and schedule the matching GPT refresh shortly afterward. Retain + // this one-shot context only after that targeting signal, with a + // bounded expiry so a later independent refresh remains independent. + context.cleanupTimer = setTimeout( + () => removePublisherDeliveryContext(context), + PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS + ); + } else { + removePublisherDeliveryContext(context); + } } }; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 6435d9708..bdb336fcd 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1694,7 +1694,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); - it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1721,15 +1721,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); - expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ - 'example-covered', - 'example-unrelated', - ]); - expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(originalRefresh).toHaveBeenCalledTimes(2); @@ -1737,7 +1733,183 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); - it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-covered-${index}`, + getTargeting: () => [], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: coveredSlots.map((_, index) => ({ + code: `example-covered-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + vi.useFakeTimers(); + try { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-targeted-${index}`, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-targeted-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let refreshAfterCallback: (() => void) | undefined; + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + const pendingRefresh = refreshAfterCallback; + refreshAfterCallback = undefined; + if (pendingRefresh) setTimeout(pendingRefresh, 750); + }); + const pbjs = installPrebidNpm(); + const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); + + pbjs.requestBids({ + adUnits: coveredCodes.map((code, index) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); + refreshAfterCallback = () => pubads.refresh(refreshSlots); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ + gamOnlySlot.getSlotElementId(), + ...coveredCodes, + ]); + expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); + + vi.advanceTimersByTime(750); + + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + + vi.runOnlyPendingTimers(); + pubads.refresh([coveredSlots[0]]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('expires a targeted delivery context before a later event-loop task', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-expiring-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), + } as any); + vi.runOnlyPendingTimers(); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('bypasses a mixed explicit delivery list spanning nested contexts', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('treats a microtask refresh without a targeting signal as an independent auction', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], From 310e62f307a8320639cee58938142de1afe3a78e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:57:56 -0500 Subject: [PATCH 025/494] Use HTTP status error type for Prebid failures --- crates/trusted-server-core/src/integrations/prebid.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 2e7655382..9366512eb 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -18,6 +18,7 @@ use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; use validator::{Validate, ValidationError}; +use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, @@ -62,7 +63,6 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -const PREBID_ERROR_TYPE_UPSTREAM_HTTP: &str = "upstream_http"; const PREBID_PUBLIC_ERROR_MESSAGE_CHARS: usize = 500; const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; @@ -2010,10 +2010,7 @@ impl PrebidAuctionProvider { let status_code = status.as_u16(); let mut auction_response = AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) - .with_metadata( - "error_type", - serde_json::json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP), - ) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) .with_metadata("http_status", serde_json::json!(status_code)) .with_metadata( "message", @@ -5143,7 +5140,7 @@ external_bundle_sri = "sha384-AAAA" ); assert_eq!( auction_response.metadata["error_type"], - json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP) + json!(ERROR_TYPE_HTTP_STATUS) ); assert_eq!(auction_response.metadata["http_status"], json!(400)); assert_eq!( From 6b4e82ed594d77fdc686107298823d83b2e3c19f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 12:04:27 -0500 Subject: [PATCH 026/494] Make auction creative rewriting optional Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior. --- CHANGELOG.md | 1 + .../src/auction/endpoints.rs | 7 +- .../src/auction/formats.rs | 141 +++++++++++++++++- .../src/auction/orchestrator.rs | 1 + .../src/auction_config_types.rs | 22 +++ .../trusted-server-core/src/config_payload.rs | 24 +++ crates/trusted-server-core/src/proxy.rs | 45 ++++++ crates/trusted-server-core/src/settings.rs | 35 +++++ docs/guide/auction-orchestration.md | 69 ++++++--- docs/guide/configuration.md | 26 +++- docs/guide/creative-processing.md | 54 +++++-- trusted-server.example.toml | 4 + 12 files changed, 382 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc49c80b..fddc8009d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory `/auction` creative sanitization while skipping first-party resource/click URL rewriting and creative TSJS injection. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 825129b1a..e7d4f0de4 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -76,9 +76,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// ## Response /// /// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's -/// `adm` field after sanitisation and first-party URL rewriting. Response -/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and -/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). +/// `adm` field after mandatory server-side sanitization. First-party resource +/// and click URL rewriting plus creative TSJS injection are enabled by default; +/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`] +/// to `false` skips only that rewrite pass. /// /// ## Scroll, refresh, and SPA navigation /// diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a18..71f9a290c 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -217,7 +217,8 @@ pub fn convert_tsjs_to_auction_request( /// Convert `OrchestrationResult` to `OpenRTB` response format. /// -/// Returns rewritten creative HTML directly in the `adm` field for inline delivery. +/// Always sanitizes creative HTML in the `adm` field and optionally rewrites it +/// according to the auction configuration. /// /// # Errors /// @@ -250,21 +251,34 @@ pub fn convert_to_openrtb_response( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Process creative HTML if present - — sanitize dangerous markup first, then rewrite URLs. + // Process creative HTML if present — always sanitize dangerous markup first. let creative_html = if let Some(ref raw_creative) = bid.creative { let sanitized = creative::sanitize_creative_html(raw_creative); - let rewritten = creative::rewrite_creative_html(settings, &sanitized); + let sanitized_len = sanitized.len(); + let rewrite_creatives = settings.auction.rewrite_creatives; + let processed = if rewrite_creatives { + creative::rewrite_creative_html(settings, &sanitized) + } else { + sanitized + }; + let rewrite_mode = if rewrite_creatives { + "enabled" + } else { + "disabled" + }; log::debug!( - "Processed creative for auction {} slot {} ({} → {} → {} bytes)", + "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, + bid.bidder, + rewrite_mode, raw_creative.len(), - sanitized.len(), - rewritten.len() + sanitized_len, + processed.len() ); - rewritten + processed } else { // No creative provided (e.g., from mediation layer that returns iframe URLs) log::warn!( @@ -445,6 +459,15 @@ mod tests { } } + fn make_complete_creative_bid() -> Bid { + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some( + r#""# + .to_string(), + ); + bid + } + fn make_result(bid: Bid) -> OrchestrationResult { OrchestrationResult { provider_responses: vec![AuctionResponse { @@ -466,6 +489,13 @@ mod tests { .expect("should parse JSON response") } + fn response_adm(response: Response) -> String { + response_json(response)["seatbid"][0]["bid"][0]["adm"] + .as_str() + .expect("should serialize adm as a string") + .to_string() + } + fn make_banner_body(config: Option) -> AdRequest { AdRequest { ad_units: vec![AdUnit { @@ -932,6 +962,103 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting enabled"); + let adm = response_adm(response); + + assert!( + adm.matches("/first-party/proxy?tsurl=").count() >= 2, + "should rewrite image and inline CSS URLs through the proxy: {adm}" + ); + assert!( + adm.contains("/first-party/click?tsurl="), + "should rewrite click URLs: {adm}" + ); + assert!( + adm.contains("data-tsclick"), + "should add the click guard attribute: {adm}" + ); + assert!( + adm.contains("tsjs-unified.min.js"), + "should inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should not retain the image URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should not retain the click URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains("url(https://styles.example.com/bg.png)"), + "should not retain the CSS URL as a direct value: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should remove malicious script content before rewriting: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should remove event handlers before rewriting: {adm}" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting disabled"); + let adm = response_adm(response); + + assert!( + adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should retain the sanitizer-accepted image URL: {adm}" + ); + assert!( + adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should retain the sanitizer-accepted click URL: {adm}" + ); + assert!( + adm.contains("url(https://styles.example.com/bg.png)"), + "should retain the sanitizer-accepted CSS URL: {adm}" + ); + assert!( + !adm.contains("/first-party/proxy"), + "should not rewrite resource URLs: {adm}" + ); + assert!( + !adm.contains("/first-party/click"), + "should not rewrite click URLs: {adm}" + ); + assert!( + !adm.contains("data-tsclick"), + "should not add the click guard attribute: {adm}" + ); + assert!( + !adm.contains("tsjs-unified.min.js"), + "should not inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should still remove malicious script content: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should still remove event handlers: {adm}" + ); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4ef73e581..fb9255825 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1818,6 +1818,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + rewrite_creatives: true, providers: vec![], mediator: None, timeout_ms: 2000, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 3bd747f64..f1d1a5cf0 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,10 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. + #[serde(default = "default_rewrite_creatives")] + pub rewrite_creatives: bool, + /// Provider names that participate in bidding /// Simply list the provider names (e.g., ["prebid", "aps"]) #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] @@ -41,6 +45,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, timeout_ms: default_timeout(), @@ -54,6 +59,10 @@ fn default_timeout() -> u32 { 2000 } +fn default_rewrite_creatives() -> bool { + true +} + fn default_creative_store() -> String { "creative_store".to_owned() } @@ -79,3 +88,16 @@ impl AuctionConfig { self.mediator.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_creatives_defaults_to_true() { + assert!( + AuctionConfig::default().rewrite_creatives, + "should enable creative rewriting by default" + ); + } +} diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index dd0b35337..58c185381 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -78,6 +78,30 @@ mod tests { ); } + #[test] + fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { + let mut data = + serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); + let auction = data + .get_mut("auction") + .and_then(serde_json::Value::as_object_mut) + .expect("should serialize auction settings as an object"); + assert!( + auction.remove("rewrite_creatives").is_some(), + "should remove the newly serialized setting" + ); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + + let reconstructed = + settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); + + assert!( + reconstructed.auction.rewrite_creatives, + "should enable creative rewriting for legacy blobs" + ); + } + #[test] fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 9e03f4dbd..9bc71fb22 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -2882,6 +2882,51 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); } + #[test] + fn auction_rewrite_setting_does_not_change_proxied_html_or_css_rewriting() { + let mut settings = create_test_settings(); + settings.auction.rewrite_creatives = false; + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + + let html = r#""#; + let mut html_response = build_http_response(StatusCode::OK, EdgeBody::from(html)); + html_response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + let html_output = finalize( + &settings, + &req, + "https://cdn.example/creative.html", + html_response, + ) + .expect("should finalize proxied HTML"); + let html_body = response_body_string(html_output); + + let css = "body{background:url(https://cdn.example/bg.png)}"; + let mut css_response = build_http_response(StatusCode::OK, EdgeBody::from(css)); + css_response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css")); + let css_output = finalize( + &settings, + &req, + "https://cdn.example/creative.css", + css_response, + ) + .expect("should finalize proxied CSS"); + let css_body = response_body_string(css_output); + + assert!( + html_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied HTML when auction rewriting is disabled: {html_body}" + ); + assert!( + css_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied CSS when auction rewriting is disabled: {css_body}" + ); + } + #[test] fn html_response_rewrite_preserves_non_standard_port() { // Verify that HTML rewriting preserves non-standard ports in sub-resource URLs. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 03cc535c8..b177a23e7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4334,6 +4334,41 @@ origin_host_header_overide = "www.example.com""#, assert!(!rewrite.is_excluded("")); } + #[test] + fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + settings.auction.rewrite_creatives, + "should preserve creative rewriting when the setting is omitted" + ); + } + + #[test] + fn test_auction_rewrite_creatives_accepts_explicit_false() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + rewrite_creatives = false + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + !settings.auction.rewrite_creatives, + "should disable creative rewriting when explicitly configured" + ); + } + #[test] fn test_auction_allowed_context_keys_defaults_to_empty() { let settings = create_test_settings(); diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d75958812..c6c82dac3 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -12,7 +12,7 @@ Key capabilities: - **Strategy-based winner selection** — Automatic strategy detection based on configuration - **Mediator support** — Optional external mediator for decoding encoded prices (e.g., APS) and applying unified floor pricing - **Provider abstraction** — Pluggable provider interface for adding new demand sources -- **Creative rewriting** — Winning creatives automatically rewritten with first-party proxy URLs +- **Creative rewriting** — Winning creatives are sanitized and rewritten with first-party proxy URLs by default ## System Flow (Prebid + APS) @@ -147,7 +147,7 @@ sequenceDiagram Note over Client,Mock: Response Assembly activate TS activate Client - Orch->>Orch: Transform to OpenRTB response
Generate iframe creatives
Rewrite creative URLs
Add orchestrator metadata + Orch->>Orch: Transform to OpenRTB response
Sanitize creative HTML
Optionally rewrite creative URLs
Add orchestrator metadata Orch-->>TS: OpenRTB BidResponse Note right of Orch: { "id": "auction-response",
"seatbid": [{ "seat": "amazon-aps",
"bid": [{ "price": 2.50,
"adm": "', + }, + }, + }; + + try { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + const oldRecord = ts.renders?.['atf_sidebar_ad']; + expect(oldRecord?.injected).toBe(false); + expect(runDeferredPlacement).toBeDefined(); + + // A newer route/adInit starts before the animation-frame retry runs while + // retaining the same publisher-owned slot element. The old callback must + // not mutate that shared element before its trace guard runs. + ts.adInit!(); + const reusedSlot = document.getElementById('div-atf-sidebar')!; + runDeferredPlacement!(0); + + expect(oldRecord?.injected).toBe(false); + expect(reusedSlot.querySelector('iframe')).toBeNull(); + expect(reusedSlot.getAttribute('data-ts-injected')).toBe('false'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('confirms a successful deferred ADM placement on the original record', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + let runDeferredPlacement: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + runDeferredPlacement = callback; + return 1; + }) + ); + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_adid: 'deferred-ad', + adm: '', + }, + }, + }; + + try { + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + const ts = (window as TestWindow).tsjs!; + ts.adInit!(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + const record = ts.renders?.atf_sidebar_ad; + const originalBookkeeping = { + seq: record?.seq, + count: record?.count, + at: record?.at, + historyLength: ts.renderLog?.length, + }; + + runDeferredPlacement!(0); + + expect(ts.renders?.atf_sidebar_ad).toBe(record); + expect(record?.injected).toBe(true); + expect({ + seq: record?.seq, + count: record?.count, + at: record?.at, + historyLength: ts.renderLog?.length, + }).toEqual(originalBookkeeping); + } finally { + vi.unstubAllGlobals(); + } + }); + it('does not attribute a later GAM refresh to the finished server-side auction', async () => { let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -1117,7 +1350,9 @@ describe('installTsRenderBridge', () => { expect(fetchStub).toHaveBeenCalledWith( 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } + // Carries an abort signal so a navigation can cancel a render belonging + // to the route it is leaving. + { mode: 'cors', signal: expect.any(AbortSignal) } ); expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); @@ -1157,6 +1392,80 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('keeps GAM and bridge signals for both arrival orders on one record per impression', async () => { + const source = createTrustedSlotIframe(); + let slotRenderListener: ((event: SlotRenderEvent) => void) | undefined; + const gptSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-header'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([gptSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, listener: (event: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') slotRenderListener = listener; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(gptSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + const ts = (window as TestWindow).tsjs!; + ts.bids.homepage_header = { + hb_adid: 'debug-first', + hb_bidder: 'mocktioneer', + }; + const bridgeListener = await captureBridgeListener(); + ts.adInit!(); + + const sendBridgeRequest = (adId: string): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId }), + ports: [{ postMessage: vi.fn() }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + + // GAM first, bridge second. + slotRenderListener!({ isEmpty: false, slot: gptSlot }); + const firstRecord = ts.renders?.homepage_header; + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + adm: '

First creative
', + }; + sendBridgeRequest('debug-first'); + expect(ts.renders?.homepage_header).toBe(firstRecord); + expect(firstRecord).toEqual( + expect.objectContaining({ count: 1, injected: true, servedFrom: 'debug-adm' }) + ); + expect(ts.renderLog).toHaveLength(1); + + // Bridge first, GAM second for the next impression. + ts.bids.homepage_header = { + hb_adid: 'debug-second', + hb_bidder: 'mocktioneer', + adm: '', + }; + ts.adInit!(); + sendBridgeRequest('debug-second'); + const secondRecord = ts.renders?.homepage_header; + slotRenderListener!({ isEmpty: false, slot: gptSlot }); + expect(ts.renders?.homepage_header).toBe(secondRecord); + expect(secondRecord).toEqual( + expect.objectContaining({ count: 2, injected: true, gamEmpty: false }) + ); + expect(ts.renderLog).toHaveLength(2); + }); + it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { // Concurrent render double-fire guard: two 'Prebid Request' messages for the // same adId can arrive before the first cache fetch settles. The in-flight @@ -1210,6 +1519,47 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('drops a PBS Cache result when the live bid changed before fetch completion', async () => { + let resolveFetch: (value: Response) => void = () => {}; + fetchStub.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const ts = (window as TestWindow).tsjs!; + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + hb_auction_id: 'auction-1', + hb_bid_id: 'bid-1', + }; + const bridgeListener = await captureBridgeListener(); + const portMessages: string[] = []; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [{ postMessage: (message: string) => portMessages.push(message) }], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + + // Same bridge ad ID and auction ID, but a different bid object/trace ID. + // Comparing only hb_adid + hb_auction_id would incorrectly accept the old + // creative and stamp it with the captured route's data. + ts.bids.homepage_header = { + ...ts.bids.homepage_header, + hb_bid_id: 'bid-2', + hb_adm_hash: 'new-creative-hash', + }; + resolveFetch({ ok: true, text: () => Promise.resolve('
Old creative
') } as Response); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(portMessages).toHaveLength(0); + expect(ts.renders?.homepage_header).toBeUndefined(); + }); + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const debugAdm = '
Debug Creative
'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defcb..58291329b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -51,6 +51,7 @@ describe('installSpaAuctionHook', () => { originalReplaceState({}, '', '/'); // Drop any ad containers inserted by a test so DOM state does not leak. document.body.innerHTML = ''; + delete (window as TestWindow).googletag; // Remove this test's popstate listener(s) so they do not fire in later tests. popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); popstateHandlers = []; @@ -304,6 +305,59 @@ describe('installSpaAuctionHook', () => { expect(adInit).toHaveBeenCalledTimes(1); }); + it('stops orphan recovery before a fast route DOM swap can replay old bids', async () => { + document.body.innerHTML = '
'; + const definedDivs: string[] = []; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + addEventListener: vi.fn(), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn((_path: string, _sizes: unknown, divId: string) => { + definedDivs.push(divId); + return { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(divId), + getTargeting: vi.fn().mockReturnValue([]), + }; + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + display: vi.fn(), + destroySlots: vi.fn(), + }; + // Keep page-bids slower than the orphan observer's 250 ms debounce. + fetchStub.mockReturnValue(new Promise(() => {})); + + await importGptModule(); + const ts = (window as TestWindow).tsjs!; + ts.adSlots = [ + { + id: 'ad-header-0', + gam_unit_path: '/123/header', + div_id: 'ad-header-0', + formats: [[728, 90]], + targeting: {}, + }, + ]; + ts.bids = { 'ad-header-0': { hb_adid: 'old-route-ad' } }; + ts.adInit!(); + expect(definedDivs).toEqual(['ad-header-0-_R_old_']); + + history.pushState({}, '', '/new-route'); + document.body.innerHTML = '
'; + await new Promise((resolve) => setTimeout(resolve, 350)); + + // The pending old-route watcher was disconnected synchronously when + // navigation began, so it never rebound or re-requested the old auction. + expect(definedDivs).toEqual(['ad-header-0-_R_old_']); + }); + it('leaves slots and bids untouched on a non-OK response', async () => { fetchStub.mockResolvedValue({ ok: false, status: 500 }); const { installSpaAuctionHook } = await importGptModule(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index c6d47eb46..4b5fffe52 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -8,6 +8,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -21,6 +22,7 @@ const { () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); + const mockOnEvent = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, @@ -28,6 +30,7 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, + onEvent: mockOnEvent, adUnits: [] as any[], }; const mockAdapterManager = { @@ -40,6 +43,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -69,7 +73,8 @@ import { auctionBidsToPrebidBids, installPrebidNpm, installRefreshHandler, - recordPrebidBidWon, + installPrebidRenderTrace, + recordPrebidAdRender, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; import type { TsjsApi } from '../../../src/core/types'; @@ -219,6 +224,7 @@ describe('prebid/auctionBidsToPrebidBids', () => { creativeId: 'KM-CREA-1', adomain: ['kargo.com'], auctionId: 'ts-auction-xyz', + bidId: 'bid-abc-1', admHash: 'a1b2c3d4e5f60718', }, ]; @@ -227,10 +233,13 @@ describe('prebid/auctionBidsToPrebidBids', () => { expect(result[0].meta.tsAuctionId).toBe('ts-auction-xyz'); expect(result[0].meta.tsAdmHash).toBe('a1b2c3d4e5f60718'); + // The bid's own OpenRTB id, distinct from the advertiser creative id. + expect(result[0].meta.tsBidId).toBe('bid-abc-1'); + expect(result[0].creativeId).toBe('KM-CREA-1'); }); }); -describe('prebid/recordPrebidBidWon', () => { +describe('prebid/recordPrebidAdRender', () => { beforeEach(() => { delete (window as { tsjs?: TsjsApi }).tsjs; document.body.innerHTML = ''; @@ -242,12 +251,19 @@ describe('prebid/recordPrebidBidWon', () => { it('records an auction-path render for a server-side bid', () => { document.body.innerHTML = '
'; - const record = recordPrebidBidWon({ - adUnitCode: 'ad-header-0-_R_x_', - bidderCode: 'kargo', - creativeId: 'KM-CREA-1', - meta: { tsAuctionId: '265dcedd-aa0a', tsAdmHash: 'f68044ca9f68c88c' }, - }); + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0-_R_x_', + bidderCode: 'kargo', + creativeId: 'KM-CREA-1', + meta: { + tsAuctionId: '265dcedd-aa0a', + tsBidId: 'bid-abc-1', + tsAdmHash: 'f68044ca9f68c88c', + }, + }, + 'succeeded' + ); expect(record).toBeDefined(); expect(record).toEqual( @@ -257,6 +273,7 @@ describe('prebid/recordPrebidBidWon', () => { rendered: true, injected: true, auctionId: '265dcedd-aa0a', + bidId: 'bid-abc-1', admHash: 'f68044ca9f68c88c', bidder: 'kargo', creativeId: 'KM-CREA-1', @@ -266,21 +283,111 @@ describe('prebid/recordPrebidBidWon', () => { ); // Written into the shared registry the panel reads. expect((window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0-_R_x_']).toBeDefined(); + // The bid id must reach the DOM as its own attribute, never folded into + // data-ts-ad-id. + const el = document.getElementById('ad-header-0-_R_x_')!; + expect(el.getAttribute('data-ts-bid-id')).toBe('bid-abc-1'); + }); + + it('records a failed render as unconfirmed, not as a green render', () => { + document.body.innerHTML = '
'; + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: '265dcedd-aa0a' }, + }, + 'failed' + ); + + expect(record).toEqual( + expect.objectContaining({ rendered: false, injected: false, visible: false }) + ); }); it('skips a bid without the server-side trace tuple (client-side bidder)', () => { - const record = recordPrebidBidWon({ - adUnitCode: 'ad-header-0', - bidderCode: 'appnexus', - meta: { advertiserDomains: ['x.com'] }, - }); + const record = recordPrebidAdRender( + { + adUnitCode: 'ad-header-0', + bidderCode: 'appnexus', + meta: { advertiserDomains: ['x.com'] }, + }, + 'succeeded' + ); expect(record).toBeUndefined(); expect((window as { tsjs?: TsjsApi }).tsjs?.renders).toBeUndefined(); }); it('skips a bid with no adUnitCode', () => { - expect(recordPrebidBidWon({ meta: { tsAuctionId: 'x' } })).toBeUndefined(); - expect(recordPrebidBidWon(undefined)).toBeUndefined(); + expect(recordPrebidAdRender({ meta: { tsAuctionId: 'x' } }, 'succeeded')).toBeUndefined(); + expect(recordPrebidAdRender(undefined, 'succeeded')).toBeUndefined(); + }); +}); + +describe('prebid/installPrebidRenderTrace', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + mockOnEvent.mockReset(); + delete (mockPbjs as { __tsRenderTraceInstalled?: boolean }).__tsRenderTraceInstalled; + }); + afterEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + document.body.innerHTML = ''; + }); + + it('confirms renders from adRenderSucceeded, never from bidWon', () => { + document.body.innerHTML = '
'; + installPrebidRenderTrace(); + + const events = mockOnEvent.mock.calls.map(([name]) => name); + expect(events).toEqual(['adRenderSucceeded', 'adRenderFailed']); + // bidWon fires when a bid is marked the winner — before the renderer runs, + // and so before the render can fail. Confirming on it would show a green + // render for a creative that never reached the page. + expect(events).not.toContain('bidWon'); + + const handlers = Object.fromEntries(mockOnEvent.mock.calls) as Record< + string, + (event: unknown) => void + >; + handlers['adRenderSucceeded']({ + bid: { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: 'auction-success', tsBidId: 'bid-success' }, + }, + }); + expect((window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0']).toEqual( + expect.objectContaining({ rendered: true, injected: true, bidId: 'bid-success' }) + ); + }); + + it('does not produce a confirmed record when the render fails after the win', () => { + document.body.innerHTML = '
'; + installPrebidRenderTrace(); + + const handlers = Object.fromEntries(mockOnEvent.mock.calls) as Record< + string, + (event: unknown) => void + >; + const bid = { + adUnitCode: 'ad-header-0', + bidderCode: 'kargo', + meta: { tsAuctionId: '265dcedd-aa0a' }, + }; + + // Prebid marks the bid as won, then its renderer fails asynchronously. + handlers['adRenderFailed']({ reason: 'exception', message: 'boom', bid }); + + const record = (window as { tsjs?: TsjsApi }).tsjs?.renders?.['ad-header-0']; + expect(record).toEqual( + expect.objectContaining({ rendered: false, injected: false, visible: false }) + ); + // No green badge and no confirmed-render attributes on the slot. + const el = document.getElementById('ad-header-0')!; + expect(el.getAttribute('data-ts-rendered')).toBe('false'); + expect(el.getAttribute('data-ts-injected')).toBe('false'); }); }); From be02574d83c09c44862d90a29f1e8497c6888562 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 20:43:59 +0530 Subject: [PATCH 084/494] Document SSAT root document 304 prevention --- ...sat-root-document-304-prevention-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md diff --git a/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md b/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md new file mode 100644 index 000000000..d790d2a83 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md @@ -0,0 +1,146 @@ +# SSAT Root Document 304 Prevention Design + +## Problem + +An auction-eligible publisher navigation can currently return `304 Not Modified` +on reload. Trusted Server starts the server-side auction before fetching the +publisher document, but a 304 has no HTML body. The HTML processor therefore +cannot inject the current request's slot state or auction result, and the browser +reuses a previously synthesized document. + +The behavior has two independent causes: + +- Trusted Server forwards browser validators (`If-None-Match` and + `If-Modified-Since`) to the publisher origin. +- The Fastly adapter sends publisher requests through its read-through cache. + +Successful synthesized HTML is also returned with `private, max-age=0` while +retaining the publisher's `ETag` and `Last-Modified`. That explicitly permits +browser storage and revalidation even though those validators describe the +unmodified origin representation, not the personalized document returned by +Trusted Server. + +## Scope + +This change applies only when the existing `should_run_ad_stack` decision is +true. That decision already limits the path to GET document navigations that are +not prefetches or bots and that have matched ad slots, permitted consent, and an +enabled auction. + +The change does not alter: + +- HEAD requests; +- bots or prefetches; +- requests without matching slots or auction consent; +- publisher requests when the auction is disabled; +- static Trusted Server assets and their intentional conditional responses; +- the `/page-bids` client-side auction endpoint; or +- auction identifiers. + +## Design + +### Publisher request + +Immediately before the publisher-origin fetch, an auction-eligible request will +remove `If-None-Match` and `If-Modified-Since`. This forces the publisher origin +to return a complete representation instead of validating a browser-cached +copy. + +The corresponding `PlatformHttpRequest` will carry an explicit, default-false +cache-bypass option. The Fastly adapter will translate that option to +`fastly::Request::set_pass(true)` before both synchronous and asynchronous sends. +All other `PlatformHttpRequest` call sites retain their current behavior because +the option defaults to false. Adapters without an intermediary read-through +cache require no runtime change. + +This bypass is deliberately scoped to the publisher fetch for an eligible SSAT +navigation. It must not be set on assets, image optimization, SSP fan-out, or +integration calls. + +### Publisher response + +When the eligible publisher response is HTML, Trusted Server will: + +- set `Cache-Control: private, no-store`; +- remove `ETag` and `Last-Modified`; +- continue removing `Surrogate-Control` and + `Fastly-Surrogate-Control`. + +`no-store` is an intentional correctness choice. It prevents the browser from +retaining a synthesized document that could later be resurrected through +revalidation. This trades away some browser back/forward-cache eligibility and +increases publisher-origin traffic, but it guarantees that an eligible +navigation receives a fresh body for SSAT injection. + +### Unexpected origin 304 + +An eligible publisher request will already be unconditional and will bypass the +Fastly cache. If the publisher nevertheless returns 304, Trusted Server will not +forward it to the browser. It will abandon the in-flight auction using a distinct +reason and return a synthetic `502 Bad Gateway` response with +`Cache-Control: private, no-store` and no validators or surrogate cache headers. + +The implementation will not retry. The first request is already unconditional +and cache-bypassed, so repeating it is unlikely to produce a body and would add +origin traffic and latency. Returning an explicit non-cacheable error is safer +than allowing the browser to reuse stale personalized HTML. + +## Data Flow + +1. Trusted Server evaluates the existing SSAT eligibility gates. +2. If eligible, it dispatches the server-side auction as it does today. +3. Before the publisher fetch, it removes browser conditional headers and marks + the platform request as cache-bypassed. +4. Fastly sends the request directly to the configured publisher backend. +5. A complete HTML response enters the existing buffering/HTML injection path. +6. Trusted Server injects current slot and auction data and removes all storage + and validation metadata before responding. +7. If the origin unexpectedly returns 304, Trusted Server abandons the auction + and returns the non-cacheable 502 instead. + +## Error Handling and Observability + +Existing publisher transport-error handling remains unchanged. The unexpected +304 case will reuse the existing abandoned-auction event mechanism with a +specific reason such as `unexpected_origin_304`, allowing it to be distinguished +from transport failures and ordinary bodiless responses. + +Noneligible publisher requests retain their existing 304 behavior. This avoids a +global semantic change to the proxy and keeps normal conditional caching intact +outside personalized SSAT documents. + +## Testing + +Tests will prove the behavior at the relevant boundaries: + +- `PlatformHttpRequest` defaults to ordinary cache behavior and its builder + enables bypass explicitly. +- The Fastly adapter applies pass mode only when requested. +- An eligible publisher request removes both conditional headers and requests a + platform cache bypass. +- A noneligible request preserves its conditional headers and ordinary cache + behavior. +- Eligible HTML receives `private, no-store` and has origin and surrogate + validators removed. +- An eligible origin 304, with or without `Content-Type`, is never returned as a + client 304 and produces one abandoned-auction observation. +- Existing HEAD, prefetch, bot, noneligible 304, asset, and `/page-bids` tests + remain unchanged. + +Targeted tests will be written before implementation. Final verification will +use the repository's target-specific test, formatting, and lint commands rather +than a bare workspace build or test. + +## Risks + +- Every eligible SSAT navigation reaches the publisher origin and transfers a + full document, increasing origin load and potentially TTFB. +- `no-store` can reduce back/forward-cache effectiveness, depending on browser + behavior. +- A publisher that incorrectly emits 304 for an unconditional request will now + expose a visible 502 instead of stale content. The distinct telemetry reason + makes this condition diagnosable. + +These costs are accepted because the requested invariant is that every eligible +SSAT navigation receives a complete document into which the current auction can +be injected. From 19c3a25268772816591760a1dd9c71cbd258dad9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 20:47:06 +0530 Subject: [PATCH 085/494] Clarify unexpected origin 304 telemetry --- .../2026-07-22-ssat-root-document-304-prevention-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md b/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md index d790d2a83..38cb1a8a6 100644 --- a/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md +++ b/docs/superpowers/specs/2026-07-22-ssat-root-document-304-prevention-design.md @@ -102,7 +102,7 @@ than allowing the browser to reuse stale personalized HTML. Existing publisher transport-error handling remains unchanged. The unexpected 304 case will reuse the existing abandoned-auction event mechanism with a -specific reason such as `unexpected_origin_304`, allowing it to be distinguished +specific reason `unexpected_origin_304`, allowing it to be distinguished from transport failures and ordinary bodiless responses. Noneligible publisher requests retain their existing 304 behavior. This avoids a From 1eb09cba99d0ef38a3ccdeca5e0c53dabe8d6256 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 20:58:27 +0530 Subject: [PATCH 086/494] Plan SSAT root document 304 prevention --- ...07-22-ssat-root-document-304-prevention.md | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md diff --git a/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md b/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md new file mode 100644 index 000000000..9f87ca9d4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md @@ -0,0 +1,612 @@ +# SSAT Root Document 304 Prevention Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Guarantee that every auction-eligible SSAT navigation receives a complete, non-stored publisher document instead of a browser- or Fastly-generated 304. + +**Architecture:** Add a default-off cache-bypass capability to the platform HTTP request and map it to Fastly pass mode. The publisher path enables it only for the existing `should_run_ad_stack` gate, strips browser validators before the origin fetch, removes response validators while setting `private, no-store`, and converts an unexpected eligible-origin 304 into a non-cacheable 502 with abandoned-auction telemetry. + +**Tech Stack:** Rust 2024, `edgezero_core` HTTP types, Fastly Rust SDK 0.12.1, async traits, Viceroy tests, `error-stack`. + +--- + +## File Map + +| File | Responsibility | +| --- | --- | +| `crates/trusted-server-core/src/platform/http.rs` | Define the platform-neutral, default-off cache-bypass request option. | +| `crates/trusted-server-core/src/platform/test_support.rs` | Record cache-bypass options in the shared stub HTTP client for publisher tests. | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Translate the platform option to Fastly `Request::set_pass(true)` in both send paths. | +| `crates/trusted-server-core/src/publisher.rs` | Apply the eligibility gate, strip validators, set the synthesized response policy, fail closed on unexpected 304, and test the complete behavior. | + +No configuration schema, JavaScript, `/page-bids`, auction-ID, asset, or integration files change. + +### Task 1: Add Platform Cache-Bypass Metadata and Test Recording + +**Files:** + +- Modify: `crates/trusted-server-core/src/platform/http.rs` +- Modify: `crates/trusted-server-core/src/platform/test_support.rs` + +- [ ] **Step 1: Write failing constructor and builder tests** + +Add these tests to `platform::http::tests`: + +```rust +#[test] +fn platform_http_request_cache_bypass_defaults_to_false() { + let request = edgezero_core::http::request_builder() + .uri("https://example.com/") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let request = PlatformHttpRequest::new(request, "origin"); + + assert!( + !request.bypass_cache, + "ordinary platform requests should retain normal cache behavior" + ); +} + +#[test] +fn platform_http_request_cache_bypass_builder_enables_bypass() { + let request = edgezero_core::http::request_builder() + .uri("https://example.com/") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let request = PlatformHttpRequest::new(request, "origin").with_cache_bypass(); + + assert!( + request.bypass_cache, + "cache-bypass builder should enable platform cache bypass" + ); +} +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: + +```bash +cargo test-fastly platform_http_request_cache_bypass -- --nocapture +``` + +Expected: compilation fails because `bypass_cache` and `with_cache_bypass` do not exist. + +- [ ] **Step 3: Add the minimal platform request option** + +Add a documented public field and builder to `PlatformHttpRequest`: + +```rust +/// Whether the platform's intermediary response cache must be bypassed. +/// +/// Adapters without an intermediary outbound cache may treat this as already +/// satisfied. The option defaults to `false` so existing call sites preserve +/// their current cache behavior. +pub bypass_cache: bool, +``` + +Initialize it to `false` in `new`, then add: + +```rust +/// Bypass the platform's intermediary response cache for this request. +#[must_use] +pub fn with_cache_bypass(mut self) -> Self { + self.bypass_cache = true; + self +} +``` + +- [ ] **Step 4: Extend the shared HTTP stub** + +Add `cache_bypass_flags: Mutex>` to `StubHttpClient`, initialize it, +record `request.bypass_cache` in both `send` and `send_async`, and expose: + +```rust +pub fn recorded_cache_bypass_flags(&self) -> Vec { + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .clone() +} +``` + +Record the flag before consuming `request.request`. + +- [ ] **Step 5: Run targeted platform tests** + +Run: + +```bash +cargo test-fastly platform_http_request_cache_bypass -- --nocapture +cargo test-fastly platform::test_support -- --nocapture +``` + +Expected: both commands pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-core/src/platform/test_support.rs +git commit -m "Add platform HTTP cache bypass option" +``` + +### Task 2: Map Cache Bypass to Fastly Pass Mode + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` + +- [ ] **Step 1: Write failing Fastly cache-override tests** + +Introduce a private helper named `apply_fastly_cache_bypass` and add tests that +construct a `fastly::Request`, invoke the helper, and inspect the SDK's derived +debug representation: + +```rust +#[test] +fn apply_fastly_cache_bypass_sets_pass_when_enabled() { + let mut request = fastly::Request::get("https://example.com/"); + + apply_fastly_cache_bypass(&mut request, true); + + assert!( + format!("{request:?}").contains("cache_override: Pass"), + "enabled bypass should select Fastly pass mode" + ); +} + +#[test] +fn apply_fastly_cache_bypass_preserves_default_when_disabled() { + let mut request = fastly::Request::get("https://example.com/"); + + apply_fastly_cache_bypass(&mut request, false); + + assert!( + format!("{request:?}").contains("cache_override: None"), + "disabled bypass should preserve Fastly read-through caching" + ); +} +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: + +```bash +cargo test-fastly apply_fastly_cache_bypass -- --nocapture +``` + +Expected: compilation fails because the helper does not exist. + +- [ ] **Step 3: Implement and use the Fastly mapping** + +Add: + +```rust +fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) { + if bypass_cache { + request.set_pass(true); + } +} +``` + +In `FastlyPlatformHttpClient::send`, copy `request.bypass_cache` before moving +the inner request, make the converted Fastly request mutable, and invoke the +helper before `.send()`. + +Do the same in `send_async` before `.send_async()`. Preserve the existing Image +Optimizer and streaming-response rejection behavior. + +- [ ] **Step 4: Run targeted Fastly adapter tests** + +Run: + +```bash +cargo test-fastly apply_fastly_cache_bypass -- --nocapture +cargo test-fastly fastly_platform_http_client -- --nocapture +``` + +Expected: all matching tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Bypass Fastly cache for marked HTTP requests" +``` + +### Task 3: Protect Eligible Publisher Requests and Successful HTML Responses + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add focused eligible- and ineligible-request test helpers** + +Add a `ssat_cache_policy_tests` module beside the existing handler-level test +modules. Reuse `StubHttpClient`, `StubBackend`, no-op services, a +non-regulated `EcContext`, a slot matching `/article`, and an enabled +orchestrator. A launch-failing provider is sufficient for these policy tests: +eligibility depends on the configured gate, not the dispatch outcome. + +The eligible request must be: + +```rust +HttpRequest::builder() + .method(Method::GET) + .uri("https://ts.example.com/article") + .header(header::HOST, "ts.example.com") + .header("sec-fetch-dest", "document") + .header(header::IF_NONE_MATCH, "\"origin-tag\"") + .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") + .body(EdgeBody::empty()) + .expect("should build eligible navigation") +``` + +Queue an origin 200 with `Content-Type: text/html`, `Cache-Control: public, +max-age=300`, `ETag`, `Last-Modified`, `Surrogate-Control`, and +`Fastly-Surrogate-Control`. + +- [ ] **Step 2: Write the failing eligible-request test** + +Drive `handle_publisher_request` and assert: + +```rust +assert_eq!(stub.recorded_cache_bypass_flags(), vec![true]); +let origin_headers = stub + .recorded_request_headers() + .into_iter() + .last() + .expect("should record publisher request headers"); +assert!(!origin_headers.iter().any(|(name, _)| name == "if-none-match")); +assert!(!origin_headers.iter().any(|(name, _)| name == "if-modified-since")); +``` + +Extract the response headers from the returned `PublisherResponse` and assert: + +```rust +assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("private, no-store")) +); +for name in [ + header::ETAG.as_str(), + header::LAST_MODIFIED.as_str(), + "surrogate-control", + "fastly-surrogate-control", +] { + assert!(response.headers().get(name).is_none(), "{name} should be removed"); +} +``` + +- [ ] **Step 3: Write the failing noneligible-request test** + +Use the existing `run_publisher_proxy` helper with no slots and the same +conditional headers. Queue a normal response and assert: + +```rust +assert_eq!(stub.recorded_cache_bypass_flags(), vec![false]); +assert!(origin_headers.iter().any(|(name, _)| name == "if-none-match")); +assert!(origin_headers.iter().any(|(name, _)| name == "if-modified-since")); +``` + +Also assert the origin's cache policy and validators remain unchanged. This is +the regression guard for HEAD, bots, prefetches, no-slot pages, and every other +request that fails the existing gate. + +- [ ] **Step 4: Run the publisher policy tests and verify they fail** + +Run: + +```bash +cargo test-fastly ssat_cache_policy_tests -- --nocapture +``` + +Expected: the eligible assertions fail because validators are forwarded, +bypass is false, the response uses `private, max-age=0`, and validators remain. + +- [ ] **Step 5: Implement request protection** + +Immediately after auction dispatch and before URI/Host rewriting, add: + +```rust +if should_run_ad_stack { + req.headers_mut().remove(header::IF_NONE_MATCH); + req.headers_mut().remove(header::IF_MODIFIED_SINCE); +} +``` + +Build the publisher request once, conditionally apply the builder, and send it: + +```rust +let platform_request = PlatformHttpRequest::new(req, backend_name); +let platform_request = if should_run_ad_stack { + platform_request.with_cache_bypass() +} else { + platform_request +}; +``` + +- [ ] **Step 6: Implement successful HTML response protection** + +Within the existing `should_run_ad_stack && is_html_content_type(...)` branch: + +```rust +response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), +); +response.headers_mut().remove(header::ETAG); +response.headers_mut().remove(header::LAST_MODIFIED); +response.headers_mut().remove("surrogate-control"); +response.headers_mut().remove("fastly-surrogate-control"); +``` + +Update the adjacent rationale: synthesized, per-navigation auction state must +not be stored or validated as though it were the origin representation. + +- [ ] **Step 7: Run targeted publisher tests** + +Run: + +```bash +cargo test-fastly ssat_cache_policy_tests -- --nocapture +cargo test-fastly publisher_request_uses_platform_http_client_with_http_types -- --nocapture +cargo test-fastly response_carries_body_preserves_bodiless_metadata -- --nocapture +``` + +Expected: all commands pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Prevent SSAT publisher document revalidation" +``` + +### Task 4: Fail Closed on an Unexpected Eligible-Origin 304 + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add a dispatching test provider** + +Within `ssat_cache_policy_tests`, add a provider whose `request_bids` sends one +request through `context.services.http_client().send_async(...)`. Give it a +stable backend name and make `parse_response` panic because an unexpected 304 +must abandon, not collect, the pending request. + +Queue responses in this order because `StubHttpClient` consumes the provider +response during `send_async` before the publisher response during `send`: + +```rust +stub.push_response(200, b"unused provider response".to_vec()); +stub.push_response_with_headers( + 304, + Vec::new(), + vec![("etag", "\"origin-tag\"")], +); +``` + +- [ ] **Step 2: Write the failing unexpected-304 test** + +Drive an eligible navigation with the dispatching provider and recording +telemetry sink. Assert the returned variant is `PublisherResponse::Buffered` +with: + +```rust +assert_eq!(response.status(), StatusCode::BAD_GATEWAY); +assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("private, no-store")) +); +assert!(response.headers().get(header::ETAG).is_none()); +assert!(response.headers().get(header::LAST_MODIFIED).is_none()); +assert!(response.headers().get("surrogate-control").is_none()); +assert!(response.headers().get("fastly-surrogate-control").is_none()); +``` + +Flatten telemetry rows and assert exactly one summary row has +`terminal_status == Some("abandoned")` and +`terminal_reason == Some("unexpected_origin_304")`. Assert no provider parse or +auction collection occurred. + +Cover both a typical 304 without `Content-Type` and a 304 carrying +`Content-Type: text/html` using a small table/helper so response classification +cannot affect the guard. + +- [ ] **Step 3: Verify the test fails** + +Run: + +```bash +cargo test-fastly unexpected_origin_304 -- --nocapture +``` + +Expected: the handler returns 304 and no `unexpected_origin_304` telemetry. + +- [ ] **Step 4: Add a noneligible-304 regression test** + +Use `run_publisher_proxy` with no slots, queue a 304 carrying `ETag`, +`Last-Modified`, and origin cache headers, and assert: + +```rust +let response = match run_publisher_proxy(&settings, &services, request).await { + PublisherResponse::Buffered(response) => response, + _ => panic!("noneligible 304 should remain a buffered response"), +}; +assert_eq!(response.status(), StatusCode::NOT_MODIFIED); +assert_eq!(response.headers().get(header::ETAG), Some(&origin_etag)); +assert_eq!( + response.headers().get(header::LAST_MODIFIED), + Some(&origin_last_modified) +); +``` + +Also assert the request used `bypass_cache == false` and preserved its incoming +conditional headers. This proves the 304-to-502 guard is eligibility-scoped +rather than global. + +- [ ] **Step 5: Implement the fail-closed guard before content classification** + +Immediately after receiving/logging the publisher response and before reading +its content type: + +```rust +if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "unexpected_origin_304", + ) + .await; + } + + let response = Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from("Publisher origin returned an invalid conditional response")) + .change_context(TrustedServerError::Proxy { + message: "failed to build unexpected origin 304 response".to_string(), + })?; + return Ok(PublisherResponse::Buffered(response)); +} +``` + +Because the response is built from a fresh builder, it contains no origin +validators or surrogate cache headers and still goes through the adapter's +normal finalization after the publisher handler returns. + +- [ ] **Step 6: Run the unexpected-304 and generic bodiless tests** + +Run: + +```bash +cargo test-fastly ssat_cache_policy_tests -- --nocapture +cargo test-fastly response_carries_body_preserves_bodiless_metadata -- --nocapture +cargo test-fastly serve_static -- --nocapture +``` + +Expected: eligible publisher 304 tests return 502; generic publisher/static +conditional semantics remain passing. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Reject unexpected SSAT origin 304 responses" +``` + +### Task 5: Full Verification and Scope Audit + +**Files:** + +- Verify only; modify production files only if a verification failure exposes a defect in the approved scope. + +- [ ] **Step 1: Format and inspect the diff** + +Run: + +```bash +cargo fmt --all +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git status --short +``` + +Expected: formatting succeeds; no whitespace errors; only the spec, plan, two +core platform files, publisher, and Fastly platform adapter are changed. Local +`fastly.toml` remains untouched. + +- [ ] **Step 2: Run all target-specific test suites** + +Run: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +./scripts/test-cli.sh +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: all tests pass. + +- [ ] **Step 3: Run JavaScript and documentation gates** + +Run from `crates/trusted-server-js/lib`: + +```bash +npx vitest run +npm run format +``` + +Then run from `docs`: + +```bash +npm run format +``` + +Expected: JavaScript tests pass and both format commands complete without +errors. Inspect `git status --short` afterward; formatting must not introduce +unrelated content changes. + +- [ ] **Step 4: Run formatting and target-specific lints/checks** + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo check-fastly +cargo check-axum +cargo check-cloudflare +cargo check-spin +``` + +Expected: all checks pass with no warnings promoted to errors. + +- [ ] **Step 5: Audit constructors and behavior boundaries** + +Run: + +```bash +rg -n "with_cache_bypass|bypass_cache|set_pass" crates +rg -n "If-None-Match|If-Modified-Since|private, no-store|unexpected_origin_304" crates/trusted-server-core/src/publisher.rs +git diff origin/main...HEAD -- fastly.toml crates/trusted-server-js +``` + +Expected: + +- `with_cache_bypass` is used only by the eligible publisher fetch. +- Fastly honors the option in both send paths. +- all other request constructors default to false; +- `fastly.toml` and JavaScript have no branch diff. + +- [ ] **Step 6: Commit any formatting-only changes if needed** + +```bash +git add crates/trusted-server-core/src/platform/http.rs \ + crates/trusted-server-core/src/platform/test_support.rs \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Format SSAT 304 prevention changes" +``` + +Skip this commit when `cargo fmt --all` produces no new diff. + +- [ ] **Step 7: Request final code review** + +Run the repository's code-review workflow against `origin/main...HEAD`. Resolve +only correctness, security, test, or approved-scope findings, then repeat the +affected verification commands before reporting completion. From 3360e6a4756e816530838aa6943dadcb9b992412 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Jul 2026 10:38:52 -0500 Subject: [PATCH 087/494] Handle oversized HEAD response metadata HEAD Content-Length describes the corresponding GET representation, not a body that will be buffered. Applying the buffered-response limit to that metadata prevents valid S3 Image Optimizer preflights from reaching their streamed GET request. Resolves: #950 --- .../src/platform.rs | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index b1bab232b..dc61d2b34 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -360,11 +360,14 @@ fn fastly_response_to_platform( mut resp: fastly::Response, backend_name: impl Into, stream_response: bool, + response_body_expected: bool, ) -> Result> { // Pre-flight: reject oversized responses before copying bytes into WASM heap. // Content-Length is advisory but covers most origin responses; chunked // responses without it fall through to the post-materialization check below. - if !stream_response + // HEAD responses report the corresponding GET size but contain no body. + if response_body_expected + && !stream_response && let Some(claimed_len) = resp .get_header("content-length") .and_then(|v| v.to_str().ok()) @@ -382,7 +385,9 @@ fn fastly_response_to_platform( for (name, value) in resp.get_headers() { builder = builder.header(name.as_str(), value.as_bytes()); } - let body = if stream_response { + let body = if !response_body_expected { + edgezero_core::body::Body::empty() + } else if stream_response { fastly_body_to_edge_stream(resp.take_body()) } else { let body_bytes = resp.take_body_bytes(); @@ -431,6 +436,7 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { let backend_name = request.backend_name.clone(); let image_optimizer = request.image_optimizer; let stream_response = request.stream_response; + let response_body_expected = request.request.method() != edgezero_core::http::Method::HEAD; let mut fastly_req = edge_request_to_fastly(request.request)?; if let Some(options) = image_optimizer { apply_fastly_image_optimizer(&mut fastly_req, options)?; @@ -438,7 +444,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { let fastly_resp = fastly_req .send(&backend_name) .change_context(PlatformError::HttpClient)?; - fastly_response_to_platform(fastly_resp, backend_name, stream_response) + fastly_response_to_platform( + fastly_resp, + backend_name, + stream_response, + response_body_expected, + ) } async fn send_async( @@ -501,7 +512,7 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { .attach("select: response has no backend name; correlation impossible")); }; ( - fastly_response_to_platform(fastly_resp, backend_name, false), + fastly_response_to_platform(fastly_resp, backend_name, false, true), None, ) } @@ -736,6 +747,55 @@ mod tests { // --- FastlyPlatformHttpClient ------------------------------------------- + #[test] + fn fastly_response_to_platform_allows_oversized_head_content_length() { + let mut fastly_response = fastly::Response::from_status(200); + fastly_response.set_header( + fastly::http::header::CONTENT_LENGTH, + (MAX_PLATFORM_RESPONSE_BODY_BYTES + 1).to_string(), + ); + + let platform_response = + fastly_response_to_platform(fastly_response, "origin", false, false) + .expect("should allow HEAD metadata for an oversized object"); + + assert_eq!( + platform_response + .response + .headers() + .get(edgezero_core::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()), + Some("10485761"), + "should preserve the origin Content-Length" + ); + assert!( + platform_response + .response + .into_body() + .into_bytes() + .unwrap_or_default() + .is_empty(), + "should return an empty HEAD response body" + ); + } + + #[test] + fn fastly_response_to_platform_rejects_oversized_buffered_get_content_length() { + let mut fastly_response = fastly::Response::from_status(200); + fastly_response.set_header( + fastly::http::header::CONTENT_LENGTH, + (MAX_PLATFORM_RESPONSE_BODY_BYTES + 1).to_string(), + ); + + let error = fastly_response_to_platform(fastly_response, "origin", false, true) + .expect_err("should reject oversized buffered GET metadata"); + + assert!( + format!("{error:?}").contains("exceeds 10485760-byte response body limit"), + "should retain the buffered response size limit: {error:?}" + ); + } + #[test] fn fastly_platform_http_client_send_returns_error_for_unregistered_backend() { let client = FastlyPlatformHttpClient; From 8bdbd61e7d3cf0e6150824cbca1b690d106d1685 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 21:08:57 +0530 Subject: [PATCH 088/494] Add platform HTTP cache bypass option --- .../trusted-server-core/src/platform/http.rs | 48 +++++++++++++++++++ .../src/platform/test_support.rs | 33 ++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index da99487a3..2e1ec51a4 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -23,6 +23,12 @@ pub struct PlatformHttpRequest { /// Adapters that cannot attach this metadata to their send path should /// return an error rather than silently dropping transformations. pub image_optimizer: Option, + /// Whether the platform's intermediary response cache must be bypassed. + /// + /// Adapters without an intermediary outbound cache may treat this as already + /// satisfied. The option defaults to `false` so existing call sites preserve + /// their current cache behavior. + pub bypass_cache: bool, /// Whether the response body should stay streaming in the platform response. /// /// Adapters that cannot preserve streaming response bodies should return an @@ -38,6 +44,7 @@ impl PlatformHttpRequest { request, backend_name: backend_name.into(), image_optimizer: None, + bypass_cache: false, stream_response: false, } } @@ -53,6 +60,13 @@ impl PlatformHttpRequest { self } + /// Bypass the platform's intermediary response cache for this request. + #[must_use] + pub fn with_cache_bypass(mut self) -> Self { + self.bypass_cache = true; + self + } + /// Preserve the upstream response body as a stream when the adapter supports it. /// /// Asset routes use this to avoid materializing large image/static responses @@ -306,8 +320,42 @@ pub trait PlatformHttpClient: Send + Sync { #[cfg(test)] mod tests { + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use super::*; + #[test] + fn platform_http_request_cache_bypass_defaults_to_false() { + let request = PlatformHttpRequest::new( + request_builder() + .body(Body::empty()) + .expect("should build request"), + "stub-backend", + ); + + assert!( + !request.bypass_cache, + "should preserve existing cache behavior by default" + ); + } + + #[test] + fn platform_http_request_cache_bypass_builder_enables_bypass() { + let request = PlatformHttpRequest::new( + request_builder() + .body(Body::empty()) + .expect("should build request"), + "stub-backend", + ) + .with_cache_bypass(); + + assert!( + request.bypass_cache, + "should enable intermediary cache bypass" + ); + } + // --------------------------------------------------------------------------- // Error-correlation interim scope (before EdgeZero #213) // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 4235b4ed7..9de2de134 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -225,6 +225,7 @@ pub(crate) struct StubHttpClient { // platforms whose send_async executes eagerly (e.g. Cloudflare Workers). concurrent_fanout: std::sync::atomic::AtomicBool, image_optimizer_options: Mutex>>, + cache_bypass_flags: Mutex>, stream_response_flags: Mutex>, request_methods: Mutex>, request_uris: Mutex>, @@ -247,6 +248,7 @@ impl StubHttpClient { select_errors: Mutex::new(VecDeque::new()), concurrent_fanout: std::sync::atomic::AtomicBool::new(true), image_optimizer_options: Mutex::new(Vec::new()), + cache_bypass_flags: Mutex::new(Vec::new()), stream_response_flags: Mutex::new(Vec::new()), request_methods: Mutex::new(Vec::new()), request_uris: Mutex::new(Vec::new()), @@ -319,6 +321,14 @@ impl StubHttpClient { .clone() } + /// Return cache-bypass flags captured per `send` or `send_async` call, in order. + pub(crate) fn recorded_cache_bypass_flags(&self) -> Vec { + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .clone() + } + /// Return streaming-response flags captured per `send` call, in order. pub fn recorded_stream_response_flags(&self) -> Vec { self.stream_response_flags @@ -376,6 +386,10 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock image optimizer options") .push(request.image_optimizer.clone()); + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .push(request.bypass_cache); self.stream_response_flags .lock() .expect("should lock stream response flags") @@ -456,6 +470,10 @@ impl PlatformHttpClient for StubHttpClient { .lock() .expect("should lock calls") .push(backend_name.clone()); + self.cache_bypass_flags + .lock() + .expect("should lock cache bypass flags") + .push(request.bypass_cache); let headers: Vec<(String, String)> = request .request @@ -715,6 +733,11 @@ mod tests { vec!["stub-backend"], "should record the backend name" ); + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "should record the default cache-bypass flag" + ); } #[test] @@ -760,8 +783,9 @@ mod tests { let pending_a = futures::executor::block_on(stub.send_async(make_req("backend-a"))) .expect("should start request a"); - let pending_b = futures::executor::block_on(stub.send_async(make_req("backend-b"))) - .expect("should start request b"); + let pending_b = + futures::executor::block_on(stub.send_async(make_req("backend-b").with_cache_bypass())) + .expect("should start request b"); assert_eq!( pending_a.backend_name(), @@ -800,6 +824,11 @@ mod tests { vec!["backend-a", "backend-b"], "should record both send_async calls in order" ); + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false, true], + "should record both send_async cache-bypass flags in order" + ); } #[test] From 15f4565a06a1c3c2506f2358eecfa086809d52a0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 21:19:08 +0530 Subject: [PATCH 089/494] Bypass Fastly cache for marked HTTP requests --- .../src/platform.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index b1bab232b..78ac11db4 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -408,6 +408,12 @@ fn fastly_response_to_platform( // FastlyPlatformHttpClient // --------------------------------------------------------------------------- +fn apply_fastly_cache_bypass(request: &mut fastly::Request, bypass_cache: bool) { + if bypass_cache { + request.set_pass(true); + } +} + /// Fastly implementation of [`PlatformHttpClient`]. /// /// - [`send`](PlatformHttpClient::send) converts the platform request to a @@ -431,10 +437,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { let backend_name = request.backend_name.clone(); let image_optimizer = request.image_optimizer; let stream_response = request.stream_response; + let bypass_cache = request.bypass_cache; let mut fastly_req = edge_request_to_fastly(request.request)?; if let Some(options) = image_optimizer { apply_fastly_image_optimizer(&mut fastly_req, options)?; } + apply_fastly_cache_bypass(&mut fastly_req, bypass_cache); let fastly_resp = fastly_req .send(&backend_name) .change_context(PlatformError::HttpClient)?; @@ -454,7 +462,9 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { return Err(Report::new(PlatformError::HttpClient) .attach("streaming responses are not supported with Fastly send_async")); } - let fastly_req = edge_request_to_fastly(request.request)?; + let bypass_cache = request.bypass_cache; + let mut fastly_req = edge_request_to_fastly(request.request)?; + apply_fastly_cache_bypass(&mut fastly_req, bypass_cache); let pending = fastly_req .send_async(&backend_name) .change_context(PlatformError::HttpClient)?; @@ -736,6 +746,26 @@ mod tests { // --- FastlyPlatformHttpClient ------------------------------------------- + #[test] + fn apply_fastly_cache_bypass_sets_pass_when_enabled() { + let mut request = fastly::Request::get("https://example.com/"); + apply_fastly_cache_bypass(&mut request, true); + assert!( + format!("{request:?}").contains("cache_override: Pass"), + "enabled bypass should select Fastly pass mode" + ); + } + + #[test] + fn apply_fastly_cache_bypass_preserves_default_when_disabled() { + let mut request = fastly::Request::get("https://example.com/"); + apply_fastly_cache_bypass(&mut request, false); + assert!( + format!("{request:?}").contains("cache_override: None"), + "disabled bypass should preserve Fastly read-through caching" + ); + } + #[test] fn fastly_platform_http_client_send_returns_error_for_unregistered_backend() { let client = FastlyPlatformHttpClient; From 92f4169a432207db29a6c081033fc016300f0c42 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 21:38:42 +0530 Subject: [PATCH 090/494] Prevent SSAT publisher document revalidation --- crates/trusted-server-core/src/publisher.rs | 251 +++++++++++++++++++- 1 file changed, 241 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 34909efe7..bd016ced0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1738,6 +1738,11 @@ pub async fn handle_publisher_request( } ); + if should_run_ad_stack { + req.headers_mut().remove(header::IF_NONE_MATCH); + req.headers_mut().remove(header::IF_MODIFIED_SINCE); + } + // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); // Strip the internal `fastly-ssl` scheme signal before forwarding to the @@ -1756,11 +1761,11 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - let mut response = match services - .http_client() - .send(PlatformHttpRequest::new(req, backend_name)) - .await - { + let mut publisher_request = PlatformHttpRequest::new(req, backend_name); + if should_run_ad_stack { + publisher_request = publisher_request.with_cache_bypass(); + } + let mut response = match services.http_client().send(publisher_request).await { Ok(platform_response) => platform_response.response, Err(err) => { if let Some(dispatched) = dispatched_auction.take() { @@ -1793,10 +1798,9 @@ pub async fn handle_publisher_request( None }; - // §4.7: HTML carrying inline per-user bid data must never be shared-cached. - // `private, max-age=0` is deliberate (not `no-store`): it keeps the page - // BFCache-eligible while restricting reuse to the same user's browser with - // revalidation; `Surrogate-Control` removal handles the Fastly shared cache. + // §4.7: HTML with synthesized per-navigation auction state must not be + // stored or validated as an origin representation. Strip both browser and + // surrogate validators/cache directives before returning it. // // Gate on `should_run_ad_stack` rather than content-type alone: when no slot // matched, the feature is disabled, or this is not an ad-eligible navigation, @@ -1813,8 +1817,10 @@ pub async fn handle_publisher_request( if should_run_ad_stack && is_html_content_type(origin_content_type) { response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("private, max-age=0"), + HeaderValue::from_static("private, no-store"), ); + response.headers_mut().remove(header::ETAG); + response.headers_mut().remove(header::LAST_MODIFIED); response.headers_mut().remove("surrogate-control"); response.headers_mut().remove("fastly-surrogate-control"); } @@ -3032,6 +3038,231 @@ mod tests { .expect("should proxy publisher request") } + mod ssat_cache_policy_tests { + use super::*; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::test_support::tests::crate_test_settings_str; + + const ORIGIN_ETAG: &str = "\"origin-tag\""; + const ORIGIN_LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; + + fn settings_with_enabled_auction_and_creative_opportunities() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\n\n\ + [creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with auction and creative opportunities enabled") + } + + fn article_slot() -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: "article-slot".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/article".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }], + floor_price: None, + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + } + } + + fn conditional_navigation_request() -> Request { + HttpRequest::builder() + .method(Method::GET) + .uri("https://ts.example.com/article") + .header(header::HOST, "ts.example.com") + .header("sec-fetch-dest", "document") + .header(header::IF_NONE_MATCH, ORIGIN_ETAG) + .header(header::IF_MODIFIED_SINCE, ORIGIN_LAST_MODIFIED) + .body(EdgeBody::empty()) + .expect("should build conditional navigation request") + } + + fn queue_cacheable_html_response(stub: &StubHttpClient) { + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ("last-modified", ORIGIN_LAST_MODIFIED), + ("surrogate-control", "max-age=300"), + ("fastly-surrogate-control", "max-age=300"), + ], + ); + } + + async fn run_with_slots( + settings: &Settings, + services: &RuntimeServices, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> PublisherResponse { + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent); + + handle_publisher_request( + settings, + services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots, + registry: None, + }, + req, + ) + .await + .expect("should proxy publisher request") + } + + fn response_head(response: PublisherResponse) -> http::response::Parts { + match response { + PublisherResponse::Buffered(response) + | PublisherResponse::Stream { response, .. } + | PublisherResponse::PassThrough { response, .. } => response.into_parts().0, + } + } + + fn recorded_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + #[tokio::test] + async fn eligible_navigation_bypasses_cache_and_returns_non_storable_html() { + // Arrange + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + queue_cacheable_html_response(&stub); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let slots = [article_slot()]; + let req = conditional_navigation_request(); + + // Act + let response = run_with_slots(&settings, &services, &slots, req).await; + let response_head = response_head(response); + + // Assert + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![true], + "eligible publisher navigation should bypass the platform cache" + ); + let recorded_requests = stub.recorded_request_headers(); + let outbound_headers = recorded_requests + .first() + .expect("should record the outbound publisher request"); + assert_eq!( + recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), + None, + "eligible publisher request should not forward If-None-Match" + ); + assert_eq!( + recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), + None, + "eligible publisher request should not forward If-Modified-Since" + ); + assert_eq!( + response_head + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "eligible HTML response should be private and non-storable" + ); + for header_name in [ + header::ETAG, + header::LAST_MODIFIED, + header::HeaderName::from_static("surrogate-control"), + header::HeaderName::from_static("fastly-surrogate-control"), + ] { + assert!( + !response_head.headers.contains_key(&header_name), + "eligible HTML response should remove {header_name}" + ); + } + } + + #[tokio::test] + async fn navigation_without_matched_slots_preserves_origin_cache_policy() { + // Arrange + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + queue_cacheable_html_response(&stub); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = conditional_navigation_request(); + + // Act + let response = run_with_slots(&settings, &services, &[], req).await; + let response_head = response_head(response); + + // Assert + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "publisher navigation without matched slots should use the default cache mode" + ); + let recorded_requests = stub.recorded_request_headers(); + let outbound_headers = recorded_requests + .first() + .expect("should record the outbound publisher request"); + assert_eq!( + recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), + Some(ORIGIN_ETAG), + "publisher request without matched slots should preserve If-None-Match" + ); + assert_eq!( + recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), + Some(ORIGIN_LAST_MODIFIED), + "publisher request without matched slots should preserve If-Modified-Since" + ); + + for (header_name, expected) in [ + (header::CACHE_CONTROL, "public, max-age=300"), + (header::ETAG, ORIGIN_ETAG), + (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), + ( + header::HeaderName::from_static("surrogate-control"), + "max-age=300", + ), + ( + header::HeaderName::from_static("fastly-surrogate-control"), + "max-age=300", + ), + ] { + assert_eq!( + response_head + .headers + .get(&header_name) + .and_then(|value| value.to_str().ok()), + Some(expected), + "publisher response without matched slots should preserve {header_name}" + ); + } + } + } + #[tokio::test] async fn publisher_request_uses_platform_http_client_with_http_types() { let settings = create_test_settings(); From fc5611a9b1235c7b614bd60b093b63b0e098e0df Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 21:55:25 +0530 Subject: [PATCH 091/494] Reject unexpected SSAT origin 304 responses --- crates/trusted-server-core/src/publisher.rs | 321 +++++++++++++++++++- 1 file changed, 320 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index bd016ced0..71a362118 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1789,6 +1789,30 @@ pub async fn handle_publisher_request( response.headers().len() ); + if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "unexpected_origin_304", + ) + .await; + } + + let response = Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CACHE_CONTROL, "private, no-store") + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from( + "Publisher origin returned an invalid conditional response", + )) + .change_context(TrustedServerError::Proxy { + message: "failed to build unexpected origin 304 response".to_string(), + })?; + return Ok(PublisherResponse::Buffered(response)); + } + let ad_slots_script = if should_run_ad_stack { settings .creative_opportunities @@ -3040,11 +3064,91 @@ mod tests { mod ssat_cache_policy_tests { use super::*; + use crate::auction::provider::AuctionProvider; + use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; + use crate::platform::test_support::{ + NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, + }; + use crate::platform::{ClientInfo, PlatformPendingRequest, PlatformResponse}; use crate::test_support::tests::crate_test_settings_str; const ORIGIN_ETAG: &str = "\"origin-tag\""; const ORIGIN_LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; + const UNEXPECTED_304_PROVIDER: &str = "example_navigation_bidder"; + const UNEXPECTED_304_BACKEND: &str = "example-navigation-bidder-backend"; + + struct DispatchingTestProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DispatchingTestProvider { + fn provider_name(&self) -> &'static str { + UNEXPECTED_304_PROVIDER + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + HttpRequest::builder() + .method(Method::POST) + .uri("https://bidder.example.com/navigation-bids") + .body(EdgeBody::empty()) + .expect("should build test provider request"), + UNEXPECTED_304_BACKEND, + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "test provider launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run for an unexpected origin 304"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name( + &self, + _services: &RuntimeServices, + _timeout_ms: u32, + ) -> Option { + Some(UNEXPECTED_304_BACKEND.to_string()) + } + } + + #[derive(Default)] + struct RecordingTelemetrySink { + batches: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionTelemetrySink for RecordingTelemetrySink { + async fn emit_auction_events( + &self, + _services: &RuntimeServices, + batch: AuctionEventBatch, + ) -> Result<(), Report> { + self.batches + .lock() + .expect("should lock telemetry batches") + .push(batch); + Ok(()) + } + } fn settings_with_enabled_auction_and_creative_opportunities() -> Settings { let toml = format!( @@ -3056,6 +3160,33 @@ mod tests { .expect("should parse settings with auction and creative opportunities enabled") } + fn settings_with_dispatching_provider() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ + [creative_opportunities]\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with the dispatching test provider") + } + + fn services_with_telemetry( + http_client: Arc, + telemetry_sink: Arc, + ) -> RuntimeServices { + let telemetry_sink: Arc = telemetry_sink; + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .auction_telemetry_sink(telemetry_sink) + .client_info(ClientInfo::default()) + .build() + } + fn article_slot() -> CreativeOpportunitySlot { CreativeOpportunitySlot { id: "article-slot".to_string(), @@ -3108,6 +3239,16 @@ mod tests { req: Request, ) -> PublisherResponse { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + run_with_orchestrator(settings, services, &orchestrator, slots, req).await + } + + async fn run_with_orchestrator( + settings: &Settings, + services: &RuntimeServices, + orchestrator: &AuctionOrchestrator, + slots: &[CreativeOpportunitySlot], + req: Request, + ) -> PublisherResponse { let consent = crate::consent::ConsentContext { jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, ..Default::default() @@ -3120,7 +3261,7 @@ mod tests { None, &mut ec_context, AuctionDispatch { - orchestrator: &orchestrator, + orchestrator, slots, registry: None, }, @@ -3261,6 +3402,184 @@ mod tests { ); } } + + #[tokio::test] + async fn eligible_navigation_rejects_unexpected_origin_304() { + for content_type in [None, Some("text/html; charset=utf-8")] { + // Arrange + let settings = settings_with_dispatching_provider(); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(DispatchingTestProvider)); + let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); + let stub = Arc::new(StubHttpClient::new()); + + // `send_async` consumes the first response before the publisher + // origin request consumes the second response. + stub.push_response(200, b"unused provider response".to_vec()); + let mut origin_headers = vec![ + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ("last-modified", ORIGIN_LAST_MODIFIED), + ("surrogate-control", "max-age=300"), + ("fastly-surrogate-control", "max-age=300"), + ]; + if let Some(content_type) = content_type { + origin_headers.push(("content-type", content_type)); + } + stub.push_response_with_headers(304, Vec::new(), origin_headers); + let services = services_with_telemetry( + Arc::clone(&stub) as Arc, + Arc::clone(&telemetry_sink), + ); + let slots = [article_slot()]; + + // Act + let response = run_with_orchestrator( + &settings, + &services, + &orchestrator, + &slots, + conditional_navigation_request(), + ) + .await; + + // Assert + let response = match response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { + panic!("unexpected origin 304 should return a buffered response") + } + }; + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "eligible origin 304 should fail closed with or without Content-Type" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "eligible origin 304 should return an explicitly non-storable response" + ); + for header_name in [ + header::ETAG, + header::LAST_MODIFIED, + header::HeaderName::from_static("surrogate-control"), + header::HeaderName::from_static("fastly-surrogate-control"), + ] { + assert!( + !response.headers().contains_key(&header_name), + "eligible origin 304 should not forward {header_name}" + ); + } + + let batches = telemetry_sink + .batches + .lock() + .expect("should lock telemetry batches"); + let summary_rows: Vec<_> = batches + .iter() + .flat_map(AuctionEventBatch::rows) + .filter(|row| row.event_kind == "summary") + .collect(); + assert_eq!( + summary_rows.len(), + 1, + "unexpected origin 304 should emit exactly one summary row" + ); + assert_eq!( + summary_rows[0].terminal_status.as_deref(), + Some("abandoned"), + "unexpected origin 304 should abandon the dispatched auction" + ); + assert_eq!( + summary_rows[0].terminal_reason.as_deref(), + Some("unexpected_origin_304"), + "unexpected origin 304 should use the bounded telemetry reason" + ); + } + } + + #[tokio::test] + async fn noneligible_origin_304_preserves_conditional_response_metadata() { + // Arrange + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 304, + Vec::new(), + vec![ + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ("last-modified", ORIGIN_LAST_MODIFIED), + ("surrogate-control", "max-age=300"), + ("fastly-surrogate-control", "max-age=300"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + // Act + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; + + // Assert + let response = match response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { + panic!("noneligible origin 304 should remain buffered") + } + }; + assert_eq!( + response.status(), + StatusCode::NOT_MODIFIED, + "noneligible origin 304 should preserve its status" + ); + for (header_name, expected) in [ + (header::CACHE_CONTROL, "public, max-age=300"), + (header::ETAG, ORIGIN_ETAG), + (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), + ( + header::HeaderName::from_static("surrogate-control"), + "max-age=300", + ), + ( + header::HeaderName::from_static("fastly-surrogate-control"), + "max-age=300", + ), + ] { + assert_eq!( + response + .headers() + .get(&header_name) + .and_then(|value| value.to_str().ok()), + Some(expected), + "noneligible origin 304 should preserve {header_name}" + ); + } + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "noneligible publisher navigation should use the default cache mode" + ); + let recorded_requests = stub.recorded_request_headers(); + let outbound_headers = recorded_requests + .first() + .expect("should record the outbound publisher request"); + assert_eq!( + recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), + Some(ORIGIN_ETAG), + "noneligible publisher request should preserve If-None-Match" + ); + assert_eq!( + recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), + Some(ORIGIN_LAST_MODIFIED), + "noneligible publisher request should preserve If-Modified-Since" + ); + } } #[tokio::test] From 79f6053ef1beb03fdf0b30733d53a38028657e4e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 22 Jul 2026 22:12:57 +0530 Subject: [PATCH 092/494] Format SSAT 304 implementation plan --- .../2026-07-22-ssat-root-document-304-prevention.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md b/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md index 9f87ca9d4..cd89dea13 100644 --- a/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md +++ b/docs/superpowers/plans/2026-07-22-ssat-root-document-304-prevention.md @@ -12,12 +12,12 @@ ## File Map -| File | Responsibility | -| --- | --- | -| `crates/trusted-server-core/src/platform/http.rs` | Define the platform-neutral, default-off cache-bypass request option. | -| `crates/trusted-server-core/src/platform/test_support.rs` | Record cache-bypass options in the shared stub HTTP client for publisher tests. | -| `crates/trusted-server-adapter-fastly/src/platform.rs` | Translate the platform option to Fastly `Request::set_pass(true)` in both send paths. | -| `crates/trusted-server-core/src/publisher.rs` | Apply the eligibility gate, strip validators, set the synthesized response policy, fail closed on unexpected 304, and test the complete behavior. | +| File | Responsibility | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/platform/http.rs` | Define the platform-neutral, default-off cache-bypass request option. | +| `crates/trusted-server-core/src/platform/test_support.rs` | Record cache-bypass options in the shared stub HTTP client for publisher tests. | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Translate the platform option to Fastly `Request::set_pass(true)` in both send paths. | +| `crates/trusted-server-core/src/publisher.rs` | Apply the eligibility gate, strip validators, set the synthesized response policy, fail closed on unexpected 304, and test the complete behavior. | No configuration schema, JavaScript, `/page-bids`, auction-ID, asset, or integration files change. From 78bb93eb13722c3d5e0f58d90bc006604e3226a6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:24:15 -0700 Subject: [PATCH 093/494] Make creative sanitization opt-in and restore creative iframe origin isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creative sanitization ran unconditionally on every markup bid, stripping `script`/`object`/`embed`/`form` and friends together with their inner content. For script-based creatives — the majority of programmatic display — that leaves nothing renderable, and the slot goes blank with no error: the leftover markup is usually a tracking pixel, so the ad server reports a successful render. Measured over 291 creative deliveries on a live publisher: a median 43% of bytes removed, 29 creatives reduced by more than 80%, and 20 reduced below 500 bytes. One bidder lost 100% of every creative; another lost 76% across 43 of them. Add `auction.sanitize_creatives` so sanitization can be disabled where creatives render in a foreign-origin frame (the Prebid Universal Creative inside the ad server's iframe), and make both creative controls opt-in: `sanitize_creatives` and `rewrite_creatives` now default to false, so a creative ships exactly as the bidder returned it unless a publisher asks for processing. Removing `allow-same-origin` from the creative iframe sandbox is part of the same change rather than a follow-up. Sanitization was documented as "the primary defense against malicious markup", with the sandbox as defense-in-depth — but the sandbox granted `allow-same-origin` alongside `allow-scripts`, which removes its origin isolation entirely. With sanitization now optional, that pairing would leave creative markup able to reach publisher cookies, storage, and same-origin fetches. The two sibling sandboxes (APS_RENDERER_SANDBOX, ADM_IFRAME_SANDBOX) already omit the token for exactly this reason; this brings the third in line, so the origin boundary no longer depends on an optional transform. Note the default change alters behaviour for deployments that never set `rewrite_creatives`: creative URL rewriting is now off unless enabled explicitly. Verified end to end: creatives pass through byte-for-byte (triplelift 8902 -> 8902, openx 22069 -> 22069, previously 100% and 35% losses), page renders with ads serving and no hydration errors. --- .../src/auction/formats.rs | 66 +++++++++++++++++-- .../src/auction/orchestrator.rs | 1 + .../src/auction_config_types.rs | 31 +++++++-- .../trusted-server-core/src/config_payload.rs | 6 +- crates/trusted-server-core/src/settings.rs | 10 ++- .../trusted-server-js/lib/src/core/render.ts | 14 ++-- .../lib/test/core/render.test.ts | 6 +- trusted-server.example.toml | 20 ++++-- 8 files changed, 130 insertions(+), 24 deletions(-) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index c2da85393..2754861d1 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -288,7 +288,12 @@ pub fn convert_to_openrtb_response( // Ordinary markup remains on the mandatory sanitize/rewrite path. A // typed renderer is serialized separately and never enters the HTML sanitizer. let (adm, ext) = if let Some(ref raw_creative) = bid.creative { - let sanitized = creative::sanitize_creative_html(raw_creative); + let sanitize_creatives = settings.auction.sanitize_creatives; + let sanitized = if sanitize_creatives { + creative::sanitize_creative_html(raw_creative) + } else { + raw_creative.clone() + }; let sanitized_len = sanitized.len(); let rewrite_creatives = settings.auction.rewrite_creatives; let processed = if rewrite_creatives { @@ -296,6 +301,11 @@ pub fn convert_to_openrtb_response( } else { sanitized }; + let sanitize_mode = if sanitize_creatives { + "enabled" + } else { + "disabled" + }; let rewrite_mode = if rewrite_creatives { "enabled" } else { @@ -303,10 +313,11 @@ pub fn convert_to_openrtb_response( }; log::debug!( - "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", + "Processed creative for auction {} slot {} bidder {} (sanitize {}, rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, bid.bidder, + sanitize_mode, rewrite_mode, raw_creative.len(), sanitized_len, @@ -1087,8 +1098,10 @@ mod tests { } #[test] - fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { - let settings = make_settings(); + fn convert_to_openrtb_response_rewrites_sanitized_creative_when_enabled() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = true; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); @@ -1135,9 +1148,52 @@ mod tests { } #[test] - fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + fn convert_to_openrtb_response_can_skip_sanitization_when_disabled() { + // Sanitization strips every executable element with its inner content, which + // destroys script-based creatives (the majority of programmatic display). + // Publishers whose creatives render in a foreign-origin frame — where the + // markup cannot reach the publisher origin — can opt out and deliver the + // creative exactly as the bidder returned it. + let mut settings = make_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with sanitization disabled"); + let adm = response_adm(response); + + assert!( + adm.contains("auction-script-marker"), + "should retain script content when sanitization is disabled: {adm}" + ); + assert!( + adm.contains("auction-handler-marker"), + "should retain event handlers when sanitization is disabled: {adm}" + ); + } + + #[test] + fn sanitize_creatives_defaults_to_disabled() { + let config = crate::auction_config_types::AuctionConfig::default(); + assert!( + !config.sanitize_creatives, + "creatives are delivered as the bidder returned them unless a publisher opts in" + ); + assert!( + !config.rewrite_creatives, + "creative URL rewriting is opt-in" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_while_sanitizing() { + // The two controls are independent: sanitization can stay on while URL + // rewriting is off. let mut settings = make_settings(); settings.auction.rewrite_creatives = false; + settings.auction.sanitize_creatives = true; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 8e4ad6c25..9a17553c9 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2122,6 +2122,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + sanitize_creatives: true, rewrite_creatives: true, providers: vec![], mediator: None, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index f1d1a5cf0..fb9ef92a0 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,19 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// Strip executable markup from winning-bid creative HTML before delivery. + /// + /// Sanitization removes `script`/`object`/`embed`/`form`/etc. **with their inner + /// content**, which blanks script-based creatives — the majority of programmatic + /// display. It is the primary defence when the creative renders in a context that + /// shares the publisher's origin. + /// + /// Disable only when creatives render in a foreign-origin frame (for example the + /// Prebid Universal Creative inside the ad server's iframe), where the markup + /// cannot reach the publisher origin. Defaults to disabled. + #[serde(default = "default_sanitize_creatives")] + pub sanitize_creatives: bool, + /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. #[serde(default = "default_rewrite_creatives")] pub rewrite_creatives: bool, @@ -45,6 +58,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + sanitize_creatives: default_sanitize_creatives(), rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, @@ -59,8 +73,12 @@ fn default_timeout() -> u32 { 2000 } +fn default_sanitize_creatives() -> bool { + false +} + fn default_rewrite_creatives() -> bool { - true + false } fn default_creative_store() -> String { @@ -94,10 +112,15 @@ mod tests { use super::*; #[test] - fn rewrite_creatives_defaults_to_true() { + fn creative_processing_defaults_to_disabled() { + let config = AuctionConfig::default(); + assert!( + !config.rewrite_creatives, + "creative rewriting is opt-in: creatives ship as the bidder returned them" + ); assert!( - AuctionConfig::default().rewrite_creatives, - "should enable creative rewriting by default" + !config.sanitize_creatives, + "creative sanitization is opt-in: it strips executable markup with its content" ); } } diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 58c185381..8842162bc 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -79,7 +79,7 @@ mod tests { } #[test] - fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { + fn legacy_blob_without_rewrite_creatives_leaves_rewriting_disabled() { let mut data = serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); let auction = data @@ -97,8 +97,8 @@ mod tests { settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); assert!( - reconstructed.auction.rewrite_creatives, - "should enable creative rewriting for legacy blobs" + !reconstructed.auction.rewrite_creatives, + "creative rewriting is opt-in: a blob without the field leaves it disabled" ); } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 4fe066997..74cc72027 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4438,7 +4438,7 @@ adSlot = "67890" } #[test] - fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + fn test_auction_creative_processing_defaults_to_false_when_omitted() { let toml_str = crate_test_settings_str() + r#" [auction] @@ -4449,8 +4449,12 @@ adSlot = "67890" let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); assert!( - settings.auction.rewrite_creatives, - "should preserve creative rewriting when the setting is omitted" + !settings.auction.rewrite_creatives, + "creative rewriting is opt-in when the setting is omitted" + ); + assert!( + !settings.auction.sanitize_creatives, + "creative sanitization is opt-in when the setting is omitted" ); } diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index ee08ef288..f00525b4e 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -7,15 +7,21 @@ import NORMALIZE_CSS from './styles/normalize.css?inline'; import IFRAME_TEMPLATE from './templates/iframe.html?raw'; // Sandbox permissions granted to creative iframes. +// // Ad creatives routinely contain scripts for tracking, click handling, and -// viewability measurement, so allow-scripts and allow-same-origin are required -// for creatives to render correctly. Server-side sanitization is the primary -// defense against malicious markup; the sandbox provides defense-in-depth. +// viewability measurement, so `allow-scripts` is required for them to render. +// +// `allow-same-origin` is deliberately excluded: combined with `allow-scripts` on +// srcdoc (or first-party src) content, that pair effectively removes the sandbox's +// origin isolation and would let SSP-provided markup run with the publisher +// origin's privileges — cookies, storage, and same-origin fetches. The origin +// boundary must not depend on server-side sanitization, which is optional +// (`auction.sanitize_creatives`) and cannot run at all for renderer-based bids. +// Matches APS_RENDERER_SANDBOX and ADM_IFRAME_SANDBOX, which already omit it. const CREATIVE_SANDBOX_TOKENS = [ 'allow-forms', 'allow-popups', 'allow-popups-to-escape-sandbox', - 'allow-same-origin', 'allow-scripts', 'allow-top-navigation-by-user-activation', ] as const; diff --git a/crates/trusted-server-js/lib/test/core/render.test.ts b/crates/trusted-server-js/lib/test/core/render.test.ts index a81486cf3..63a33c8a9 100644 --- a/crates/trusted-server-js/lib/test/core/render.test.ts +++ b/crates/trusted-server-js/lib/test/core/render.test.ts @@ -31,8 +31,12 @@ describe('render', () => { expect(sandbox).toContain('allow-popups'); expect(sandbox).toContain('allow-popups-to-escape-sandbox'); expect(sandbox).toContain('allow-top-navigation-by-user-activation'); - expect(sandbox).toContain('allow-same-origin'); expect(sandbox).toContain('allow-scripts'); + // `allow-scripts` + `allow-same-origin` together defeat the sandbox: creative + // markup would run with the publisher origin's privileges (cookies, storage, + // same-origin fetches). Matches APS_RENDERER_SANDBOX and ADM_IFRAME_SANDBOX, + // which already omit it. + expect(sandbox).not.toContain('allow-same-origin'); }); it('preserves dollar sequences when building the creative document', async () => { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index ef3edc2af..7cd16133e 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -112,10 +112,22 @@ rewrite_script = true [auction] enabled = false -# Defaults to true. Set false to return sanitized but unre-written winning-bid adm, -# skipping proxy/click URL conversion and creative TSJS injection. -# Sanitization is always applied and cannot be disabled by this setting. -rewrite_creatives = true +# Defaults to false. Set true to rewrite winning-bid adm to first-party endpoints, +# converting proxy/click URLs and injecting the creative TSJS runtime. +# Sanitization is controlled separately by `sanitize_creatives` below. +rewrite_creatives = false +# Strip executable markup (script/object/embed/form/...) from winning-bid adm, +# removing those elements together with their inner content. +# +# Defaults to false: creatives are delivered exactly as the bidder returned them. +# Enable whenever creatives can render in a context that shares the publisher's +# origin — it is the primary defence there. +# +# Set false only when creatives render in a foreign-origin frame (for example the +# Prebid Universal Creative inside the ad server's iframe), where the markup cannot +# reach the publisher origin. Sanitization removes script-based creatives entirely, +# so leaving it enabled on a script-heavy demand stack silently blanks those slots. +sanitize_creatives = false providers = [] timeout_ms = 2000 allowed_context_keys = [] From b4f5def9160b79863e7b8ae4342c78440e54dace Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:24:15 -0700 Subject: [PATCH 094/494] Make creative sanitization opt-in and restore creative iframe origin isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creative sanitization ran unconditionally on every markup bid, stripping `script`/`object`/`embed`/`form` and friends together with their inner content. For script-based creatives — the majority of programmatic display — that leaves nothing renderable, and the slot goes blank with no error: the leftover markup is usually a tracking pixel, so the ad server reports a successful render. Measured over 291 creative deliveries on a live publisher: a median 43% of bytes removed, 29 creatives reduced by more than 80%, and 20 reduced below 500 bytes. One bidder lost 100% of every creative; another lost 76% across 43 of them. Add `auction.sanitize_creatives` so sanitization can be disabled where creatives render in a foreign-origin frame (the Prebid Universal Creative inside the ad server's iframe), and make both creative controls opt-in: `sanitize_creatives` and `rewrite_creatives` now default to false, so a creative ships exactly as the bidder returned it unless a publisher asks for processing. Removing `allow-same-origin` from the creative iframe sandbox is part of the same change rather than a follow-up. Sanitization was documented as "the primary defense against malicious markup", with the sandbox as defense-in-depth — but the sandbox granted `allow-same-origin` alongside `allow-scripts`, which removes its origin isolation entirely. With sanitization now optional, that pairing would leave creative markup able to reach publisher cookies, storage, and same-origin fetches. The two sibling sandboxes (APS_RENDERER_SANDBOX, ADM_IFRAME_SANDBOX) already omit the token for exactly this reason; this brings the third in line, so the origin boundary no longer depends on an optional transform. Note the default change alters behaviour for deployments that never set `rewrite_creatives`: creative URL rewriting is now off unless enabled explicitly. Verified end to end: creatives pass through byte-for-byte (triplelift 8902 -> 8902, openx 22069 -> 22069, previously 100% and 35% losses), page renders with ads serving and no hydration errors. --- .../src/auction/formats.rs | 69 +++++++++++++++-- .../src/auction/orchestrator.rs | 1 + .../src/auction_config_types.rs | 74 ++++++++++++++++--- .../trusted-server-core/src/config_payload.rs | 8 +- crates/trusted-server-core/src/settings.rs | 10 ++- .../trusted-server-js/lib/src/core/render.ts | 14 +++- .../lib/test/core/render.test.ts | 6 +- trusted-server.example.toml | 22 ++++-- 8 files changed, 172 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 71f9a290c..b23331552 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -251,9 +251,15 @@ pub fn convert_to_openrtb_response( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Process creative HTML if present — always sanitize dangerous markup first. + // Process creative HTML if present. Sanitization is opt-in: when disabled + // the creative ships exactly as the bidder returned it. let creative_html = if let Some(ref raw_creative) = bid.creative { - let sanitized = creative::sanitize_creative_html(raw_creative); + let sanitize_creatives = settings.auction.sanitize_creatives; + let sanitized = if sanitize_creatives { + creative::sanitize_creative_html(raw_creative) + } else { + raw_creative.clone() + }; let sanitized_len = sanitized.len(); let rewrite_creatives = settings.auction.rewrite_creatives; let processed = if rewrite_creatives { @@ -261,6 +267,11 @@ pub fn convert_to_openrtb_response( } else { sanitized }; + let sanitize_mode = if sanitize_creatives { + "enabled" + } else { + "disabled" + }; let rewrite_mode = if rewrite_creatives { "enabled" } else { @@ -268,10 +279,11 @@ pub fn convert_to_openrtb_response( }; log::debug!( - "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", + "Processed creative for auction {} slot {} bidder {} (sanitize {}, rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, bid.bidder, + sanitize_mode, rewrite_mode, raw_creative.len(), sanitized_len, @@ -963,8 +975,10 @@ mod tests { } #[test] - fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { - let settings = make_settings(); + fn convert_to_openrtb_response_rewrites_sanitized_creative_when_enabled() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = true; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); @@ -1011,9 +1025,52 @@ mod tests { } #[test] - fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + fn convert_to_openrtb_response_can_skip_sanitization_when_disabled() { + // Sanitization strips every executable element with its inner content, which + // destroys script-based creatives (the majority of programmatic display). + // Publishers whose creatives render in a foreign-origin frame — where the + // markup cannot reach the publisher origin — can opt out and deliver the + // creative exactly as the bidder returned it. + let mut settings = make_settings(); + settings.auction.sanitize_creatives = false; + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with sanitization disabled"); + let adm = response_adm(response); + + assert!( + adm.contains("auction-script-marker"), + "should retain script content when sanitization is disabled: {adm}" + ); + assert!( + adm.contains("auction-handler-marker"), + "should retain event handlers when sanitization is disabled: {adm}" + ); + } + + #[test] + fn sanitize_creatives_defaults_to_disabled() { + let config = crate::auction_config_types::AuctionConfig::default(); + assert!( + !config.sanitize_creatives, + "creatives are delivered as the bidder returned them unless a publisher opts in" + ); + assert!( + !config.rewrite_creatives, + "creative URL rewriting is opt-in" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_while_sanitizing() { + // The two controls are independent: sanitization can stay on while URL + // rewriting is off. let mut settings = make_settings(); settings.auction.rewrite_creatives = false; + settings.auction.sanitize_creatives = true; let auction_request = make_auction_request(); let result = make_result(make_complete_creative_bid()); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index b018518d5..68cc27289 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1823,6 +1823,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + sanitize_creatives: true, rewrite_creatives: true, providers: vec![], mediator: None, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index eb93adbd1..f05c4df22 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,22 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// Strip executable markup from winning-bid creative HTML before delivery. + /// + /// Sanitization removes `script`/`object`/`embed`/`form`/etc. **with their inner + /// content**, which blanks script-based creatives — the majority of programmatic + /// display. It is the primary defence when the creative renders in a context that + /// shares the publisher's origin. + /// + /// Disable only when creatives render in a foreign-origin frame (for example the + /// Prebid Universal Creative inside the ad server's iframe), where the markup + /// cannot reach the publisher origin. Defaults to disabled. + #[serde( + default = "default_sanitize_creatives", + skip_serializing_if = "is_default_sanitize_creatives" + )] + pub sanitize_creatives: bool, + /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. #[serde( default = "default_rewrite_creatives", @@ -48,6 +64,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + sanitize_creatives: default_sanitize_creatives(), rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, @@ -62,14 +79,22 @@ fn default_timeout() -> u32 { 2000 } +fn default_sanitize_creatives() -> bool { + false +} + fn default_rewrite_creatives() -> bool { - true + false } fn is_default_rewrite_creatives(value: &bool) -> bool { *value == default_rewrite_creatives() } +fn is_default_sanitize_creatives(value: &bool) -> bool { + *value == default_sanitize_creatives() +} + fn default_creative_store() -> String { "creative_store".to_owned() } @@ -101,13 +126,17 @@ mod tests { use super::*; #[test] - fn rewrite_creatives_defaults_to_true() { + fn creative_processing_defaults_to_disabled() { let config: AuctionConfig = serde_json::from_value(serde_json::json!({})).expect("should deserialize defaults"); assert!( - config.rewrite_creatives, - "should enable creative rewriting by default" + !config.rewrite_creatives, + "creative rewriting is opt-in: creatives ship as the bidder returned them" + ); + assert!( + !config.sanitize_creatives, + "creative sanitization is opt-in: it strips executable markup with its content" ); } @@ -123,17 +152,44 @@ mod tests { } #[test] - fn disabled_rewrite_creatives_is_serialized() { + fn enabled_rewrite_creatives_is_serialized() { let config = AuctionConfig { - rewrite_creatives: false, + rewrite_creatives: true, ..AuctionConfig::default() }; - let serialized = serde_json::to_value(config).expect("should serialize disabled rewriting"); + let serialized = serde_json::to_value(config).expect("should serialize enabled rewriting"); assert_eq!( serialized.get("rewrite_creatives"), - Some(&serde_json::Value::Bool(false)), - "should preserve an explicit rewrite opt-out" + Some(&serde_json::Value::Bool(true)), + "should preserve an explicit rewrite opt-in" + ); + } + + #[test] + fn default_sanitize_creatives_is_not_serialized() { + let serialized = + serde_json::to_value(AuctionConfig::default()).expect("should serialize defaults"); + + assert!( + serialized.get("sanitize_creatives").is_none(), + "should omit the default sanitize setting" + ); + } + + #[test] + fn enabled_sanitize_creatives_is_serialized() { + let config = AuctionConfig { + sanitize_creatives: true, + ..AuctionConfig::default() + }; + let serialized = + serde_json::to_value(config).expect("should serialize enabled sanitization"); + + assert_eq!( + serialized.get("sanitize_creatives"), + Some(&serde_json::Value::Bool(true)), + "should preserve an explicit sanitize opt-in" ); } } diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index f7ae531ea..bf32b0102 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -97,8 +97,8 @@ mod tests { } #[test] - fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { - let data = + fn legacy_blob_without_rewrite_creatives_leaves_rewriting_disabled() { + let mut data = serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); let auction = data .get("auction") @@ -115,8 +115,8 @@ mod tests { settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); assert!( - reconstructed.auction.rewrite_creatives, - "should enable creative rewriting for legacy blobs" + !reconstructed.auction.rewrite_creatives, + "creative rewriting is opt-in: a blob without the field leaves it disabled" ); } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index bdba093b7..ef5130d3d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4380,7 +4380,7 @@ origin_host_header_overide = "www.example.com""#, } #[test] - fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + fn test_auction_creative_processing_defaults_to_false_when_omitted() { let toml_str = crate_test_settings_str() + r#" [auction] @@ -4391,8 +4391,12 @@ origin_host_header_overide = "www.example.com""#, let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); assert!( - settings.auction.rewrite_creatives, - "should preserve creative rewriting when the setting is omitted" + !settings.auction.rewrite_creatives, + "creative rewriting is opt-in when the setting is omitted" + ); + assert!( + !settings.auction.sanitize_creatives, + "creative sanitization is opt-in when the setting is omitted" ); } diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index ee08ef288..f00525b4e 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -7,15 +7,21 @@ import NORMALIZE_CSS from './styles/normalize.css?inline'; import IFRAME_TEMPLATE from './templates/iframe.html?raw'; // Sandbox permissions granted to creative iframes. +// // Ad creatives routinely contain scripts for tracking, click handling, and -// viewability measurement, so allow-scripts and allow-same-origin are required -// for creatives to render correctly. Server-side sanitization is the primary -// defense against malicious markup; the sandbox provides defense-in-depth. +// viewability measurement, so `allow-scripts` is required for them to render. +// +// `allow-same-origin` is deliberately excluded: combined with `allow-scripts` on +// srcdoc (or first-party src) content, that pair effectively removes the sandbox's +// origin isolation and would let SSP-provided markup run with the publisher +// origin's privileges — cookies, storage, and same-origin fetches. The origin +// boundary must not depend on server-side sanitization, which is optional +// (`auction.sanitize_creatives`) and cannot run at all for renderer-based bids. +// Matches APS_RENDERER_SANDBOX and ADM_IFRAME_SANDBOX, which already omit it. const CREATIVE_SANDBOX_TOKENS = [ 'allow-forms', 'allow-popups', 'allow-popups-to-escape-sandbox', - 'allow-same-origin', 'allow-scripts', 'allow-top-navigation-by-user-activation', ] as const; diff --git a/crates/trusted-server-js/lib/test/core/render.test.ts b/crates/trusted-server-js/lib/test/core/render.test.ts index a81486cf3..63a33c8a9 100644 --- a/crates/trusted-server-js/lib/test/core/render.test.ts +++ b/crates/trusted-server-js/lib/test/core/render.test.ts @@ -31,8 +31,12 @@ describe('render', () => { expect(sandbox).toContain('allow-popups'); expect(sandbox).toContain('allow-popups-to-escape-sandbox'); expect(sandbox).toContain('allow-top-navigation-by-user-activation'); - expect(sandbox).toContain('allow-same-origin'); expect(sandbox).toContain('allow-scripts'); + // `allow-scripts` + `allow-same-origin` together defeat the sandbox: creative + // markup would run with the publisher origin's privileges (cookies, storage, + // same-origin fetches). Matches APS_RENDERER_SANDBOX and ADM_IFRAME_SANDBOX, + // which already omit it. + expect(sandbox).not.toContain('allow-same-origin'); }); it('preserves dollar sequences when building the creative document', async () => { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 99fabf3f2..d21a56ac7 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -112,11 +112,23 @@ rewrite_script = true [auction] enabled = false -# Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4 -# environment override. Set false to return sanitized but unre-written winning-bid -# adm, skipping proxy/click URL conversion and creative TSJS injection. -# Sanitization is always applied. Restore and push true before an older-binary rollback. -rewrite_creatives = true +# Defaults to false. Keep this leaf present when using the EdgeZero v0.0.4 +# environment override. Set true to rewrite winning-bid adm to first-party +# endpoints, converting proxy/click URLs and injecting the creative TSJS runtime. +# Sanitization is controlled separately by `sanitize_creatives` below. +rewrite_creatives = false +# Strip executable markup (script/object/embed/form/...) from winning-bid adm, +# removing those elements together with their inner content. +# +# Defaults to false: creatives are delivered exactly as the bidder returned them. +# Enable whenever creatives can render in a context that shares the publisher's +# origin — it is the primary defence there. +# +# Leave disabled when creatives render in a foreign-origin frame (for example the +# Prebid Universal Creative inside the ad server's iframe), where the markup cannot +# reach the publisher origin. Sanitization removes script-based creatives entirely, +# so enabling it on a script-heavy demand stack silently blanks those slots. +sanitize_creatives = false providers = [] timeout_ms = 2000 allowed_context_keys = [] From f59fd85a5830edcaab437603799702c34bb1c83b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 13:24:31 +0530 Subject: [PATCH 095/494] Add gam_unit_path template parser --- .../src/creative_opportunities.rs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 9ce741f3f..15bf95e69 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -14,6 +14,72 @@ use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::price_bucket::PriceGranularity; use crate::settings::vec_from_seq_or_map; +/// A single parsed segment of a [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) template. +#[derive(Debug, Clone)] +pub(crate) enum UnitTemplatePart { + /// Verbatim text between placeholders. + Literal(String), + /// `{network_id}` — replaced with the GAM network id. + NetworkId, + /// `{section}` — replaced with the request-derived section. + Section, + /// `{slot_id}` — replaced with the slot id. + SlotId, +} + +/// Parses a `gam_unit_path` template into an ordered list of parts. +/// +/// Supported placeholders: `{network_id}`, `{section}`, `{slot_id}`. A template +/// with no placeholders is a single [`UnitTemplatePart::Literal`] and renders +/// verbatim. +/// +/// # Errors +/// +/// Returns an error string for an empty template, an unmatched or nested `{`, +/// a stray `}`, or an unknown placeholder name. +fn parse_unit_template(raw: &str) -> Result, String> { + if raw.is_empty() { + return Err("gam_unit_path template must not be empty".to_string()); + } + let mut parts = Vec::new(); + let mut literal = String::new(); + let mut chars = raw.chars(); + while let Some(c) = chars.next() { + match c { + '{' => { + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(std::mem::take(&mut literal))); + } + let mut name = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some('{') => return Err(format!("nested '{{' in template `{raw}`")), + Some(ch) => name.push(ch), + None => return Err(format!("unmatched '{{' in template `{raw}`")), + } + } + match name.as_str() { + "network_id" => parts.push(UnitTemplatePart::NetworkId), + "section" => parts.push(UnitTemplatePart::Section), + "slot_id" => parts.push(UnitTemplatePart::SlotId), + other => { + return Err(format!( + "unknown placeholder `{{{other}}}` in template `{raw}`" + )); + } + } + } + '}' => return Err(format!("stray '}}' in template `{raw}`")), + other => literal.push(other), + } + } + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(literal)); + } + Ok(parts) +} + /// Top-level configuration for the creative opportunities system. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -554,6 +620,46 @@ mod tests { assert_eq!(slot.resolved_div_id(), "atf"); } + #[test] + fn parse_unit_template_accepts_known_placeholders() { + let parts = parse_unit_template("/{network_id}/autoblog/{section}") + .expect("should parse valid template"); + assert_eq!(parts.len(), 4, "should split into literal+ph+literal+ph"); + } + + #[test] + fn parse_unit_template_accepts_static_path() { + let parts = parse_unit_template("/88059007/autoblog/homepage") + .expect("should parse a static path as a single literal"); + assert!( + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/88059007/autoblog/homepage"), + "should be one literal part" + ); + } + + #[test] + fn parse_unit_template_rejects_unknown_placeholder() { + let err = parse_unit_template("/{network_id}/{oops}") + .expect_err("should reject unknown placeholder"); + assert!(err.contains("oops"), "error should name the bad placeholder"); + } + + #[test] + fn parse_unit_template_rejects_unmatched_brace() { + parse_unit_template("/{network_id}/{section").expect_err("should reject unmatched '{'"); + parse_unit_template("/a}b").expect_err("should reject stray '}'"); + } + + #[test] + fn parse_unit_template_rejects_nested_brace() { + parse_unit_template("/{net{work}_id}").expect_err("should reject nested '{'"); + } + + #[test] + fn parse_unit_template_rejects_empty() { + parse_unit_template("").expect_err("should reject empty template"); + } + #[test] fn validate_runtime_rejects_empty_div_id_override() { // An empty/whitespace div_id would resolve every slot to the first From 9a71f556920215067c6f52cca12044945844a389 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 13:31:04 +0530 Subject: [PATCH 096/494] Add request-path section derivation --- .../src/creative_opportunities.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 15bf95e69..0f5dc9acc 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -80,6 +80,39 @@ fn parse_unit_template(raw: &str) -> Result, String> { Ok(parts) } +/// Collapses each run of characters outside `[A-Za-z0-9_-]` to a single `_`. +/// +/// Returns a non-empty string for any non-empty input. +fn sanitize_section(segment: &str) -> String { + let mut out = String::with_capacity(segment.len()); + let mut in_bad_run = false; + for ch in segment.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + out.push(ch); + in_bad_run = false; + } else if !in_bad_run { + out.push('_'); + in_bad_run = true; + } + } + out +} + +/// Derives the `{section}` value from a request path. +/// +/// Uses the first non-empty path segment, sanitized to `[A-Za-z0-9_-]`. Falls +/// back to `section_root` when the path has no segment (`/`, repeated slashes). +/// +/// The path is used **raw** (not percent-decoded) so this stays consistent with +/// how [`page_patterns`](CreativeOpportunitySlot::page_patterns) glob-match the +/// same path — e.g. `/new%20s` yields `new_20s`, never the decoded `new_s`. +pub(crate) fn derive_section(path: &str, section_root: &str) -> String { + match path.split('/').find(|segment| !segment.is_empty()) { + Some(segment) => sanitize_section(segment), + None => section_root.to_string(), + } +} + /// Top-level configuration for the creative opportunities system. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -660,6 +693,35 @@ mod tests { parse_unit_template("").expect_err("should reject empty template"); } + #[test] + fn derive_section_uses_first_segment() { + assert_eq!(derive_section("/news", "home"), "news"); + assert_eq!(derive_section("/news/gm-cadillac", "home"), "news"); + assert_eq!(derive_section("/car-research/x", "home"), "car-research"); + } + + #[test] + fn derive_section_uses_root_when_no_segment() { + assert_eq!(derive_section("/", "homepage"), "homepage"); + assert_eq!(derive_section("///", "homepage"), "homepage"); + } + + #[test] + fn derive_section_sanitizes_unsafe_runs_to_single_underscore() { + // Not decoded: in "new%20s" only '%' is disallowed ('2' and '0' are + // alphanumeric), so it collapses to a single '_' -> "new_20s". This is + // exactly the no-decode contract: had we decoded, %20 would be a space + // and yield "new_s"; we do NOT decode. + assert_eq!(derive_section("/new%20s", "home"), "new_20s"); + // A run of disallowed chars collapses to one '_'. + assert_eq!(derive_section("/a..b", "home"), "a_b"); + } + + #[test] + fn derive_section_is_non_empty_for_all_disallowed_segment() { + assert_eq!(derive_section("/%%%/x", "home"), "_"); + } + #[test] fn validate_runtime_rejects_empty_div_id_override() { // An empty/whitespace div_id would resolve every slot to the first From 75758730b7acfbd1e4f8779b43a90982538e8e9f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 15:02:58 +0530 Subject: [PATCH 097/494] Add section_root, unit-template compile/render, and startup validation --- .../src/creative_opportunities.rs | 223 ++++++++++++++++-- crates/trusted-server-core/src/publisher.rs | 4 + 2 files changed, 208 insertions(+), 19 deletions(-) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 0f5dc9acc..59ce6d46b 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -139,6 +139,14 @@ pub struct CreativeOpportunitiesConfig { /// Price granularity for header-bidding price bucketing. Defaults to `Dense`. #[serde(default)] pub price_granularity: PriceGranularity, + /// Value substituted for `{section}` when the request path has no first + /// segment (e.g. `/`). + /// + /// Required when any slot's [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) + /// template contains `{section}`. No default — a home-section name is + /// publisher-specific, so the URL→section convention stays in config, not core. + #[serde(default)] + pub section_root: Option, /// Slot templates. Empty vec = feature disabled (no auction fired, no globals injected). #[serde(default, deserialize_with = "vec_from_seq_or_map")] pub slot: Vec, @@ -152,15 +160,48 @@ impl CreativeOpportunitiesConfig { } } + /// Parse every slot's [`gam_unit_path`](CreativeOpportunitySlot::gam_unit_path) + /// template. Call once after deserialization, before [`validate_runtime`](Self::validate_runtime). + /// + /// # Errors + /// + /// Returns an error string when any slot's template is malformed. + pub fn compile_unit_templates(&mut self) -> Result<(), String> { + for slot in &mut self.slot { + slot.compile_unit_template()?; + } + Ok(()) + } + /// Validate all slot definitions after runtime preparation. /// /// # Errors /// /// Returns an error string when a slot has an invalid identifier, page - /// pattern set, format list, dimensions, or resolved GAM unit path. + /// pattern set, format list, or dimensions, or when a slot's `gam_unit_path` + /// template uses `{section}` without a valid [`section_root`](Self::section_root). pub fn validate_runtime(&self) -> Result<(), String> { for slot in &self.slot { - slot.validate_runtime(&self.gam_network_id)?; + slot.validate_runtime()?; + } + + if self + .slot + .iter() + .any(CreativeOpportunitySlot::template_uses_section) + { + match self.section_root.as_deref() { + Some(root) + if !root.is_empty() + && root + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => {} + _ => { + return Err("section_root is required and must match [A-Za-z0-9_-]+ \ + when a gam_unit_path template uses {section}" + .to_string()); + } + } } Ok(()) @@ -205,6 +246,14 @@ pub struct CreativeOpportunitySlot { /// crate can construct slots via struct-literal syntax with an empty cache. #[serde(skip, default)] pub(crate) compiled_patterns: Vec, + /// Pre-parsed [`gam_unit_path`](Self::gam_unit_path) template, populated by + /// [`compile_unit_template`](Self::compile_unit_template) at startup. + /// + /// `None` when the slot has no explicit `gam_unit_path` (renders the default + /// `//`). `pub(crate)` so cross-module test helpers can build + /// slots via struct-literal syntax with an empty cache. + #[serde(skip, default)] + pub(crate) compiled_unit: Option>, } impl CreativeOpportunitySlot { @@ -214,7 +263,7 @@ impl CreativeOpportunitySlot { /// /// Returns an error string when required slot fields are empty, invalid, /// or semantically unusable at runtime. - pub fn validate_runtime(&self, gam_network_id: &str) -> Result<(), String> { + pub fn validate_runtime(&self) -> Result<(), String> { validate_slot_id(&self.id)?; if self.page_patterns.is_empty() { @@ -269,15 +318,14 @@ impl CreativeOpportunitySlot { )); } - if self - .resolved_gam_unit_path(gam_network_id) - .trim() - .is_empty() + // A present-but-blank `gam_unit_path` renders to an empty/whitespace + // unit path. An empty string also fails template parsing at startup; + // this keeps the slot-level check self-contained (tests call + // `validate_runtime` without compiling templates first). + if let Some(raw) = &self.gam_unit_path + && raw.trim().is_empty() { - return Err(format!( - "slot `{}` resolved GAM unit path must not be empty", - self.id - )); + return Err(format!("slot `{}` gam_unit_path must not be empty", self.id)); } Ok(()) @@ -364,6 +412,52 @@ impl CreativeOpportunitySlot { .unwrap_or_else(|| format!("/{}/{}", gam_network_id, self.id)) } + /// Parses [`gam_unit_path`](Self::gam_unit_path) into + /// [`compiled_unit`](Self::compiled_unit). Call once at startup via + /// [`CreativeOpportunitiesConfig::compile_unit_templates`]. + /// + /// # Errors + /// + /// Returns an error string (prefixed with the slot id) when the template is + /// malformed. See [`parse_unit_template`]. + pub fn compile_unit_template(&mut self) -> Result<(), String> { + self.compiled_unit = match &self.gam_unit_path { + Some(raw) => { + Some(parse_unit_template(raw).map_err(|e| format!("slot `{}`: {e}", self.id))?) + } + None => None, + }; + Ok(()) + } + + /// Renders the resolved GAM unit path for a given network id and section. + /// + /// Substitutes `{network_id}`, `{section}`, and `{slot_id}` in the parsed + /// template. Falls back to `//` when the slot has no template. + #[must_use] + pub fn render_gam_unit_path(&self, gam_network_id: &str, section: &str) -> String { + match &self.compiled_unit { + Some(parts) => parts + .iter() + .map(|part| match part { + UnitTemplatePart::Literal(s) => s.as_str(), + UnitTemplatePart::NetworkId => gam_network_id, + UnitTemplatePart::Section => section, + UnitTemplatePart::SlotId => self.id.as_str(), + }) + .collect(), + None => format!("/{}/{}", gam_network_id, self.id), + } + } + + /// Returns `true` if this slot's compiled template contains `{section}`. + #[must_use] + pub(crate) fn template_uses_section(&self) -> bool { + self.compiled_unit + .as_ref() + .is_some_and(|parts| parts.iter().any(|p| matches!(p, UnitTemplatePart::Section))) + } + /// Returns the div element ID for this slot. /// /// Returns the [`div_id`](Self::div_id) override when set, otherwise returns [`id`](Self::id). @@ -554,6 +648,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, } } @@ -722,6 +817,96 @@ mod tests { assert_eq!(derive_section("/%%%/x", "home"), "_"); } + fn make_config_with_section_template(section_root: Option<&str>) -> CreativeOpportunitiesConfig { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + CreativeOpportunitiesConfig { + gam_network_id: "88059007".to_string(), + auction_timeout_ms: None, + price_granularity: PriceGranularity::default(), + section_root: section_root.map(str::to_string), + slot: vec![slot], + } + } + + #[test] + fn render_gam_unit_path_substitutes_placeholders() { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + slot.compile_unit_template().expect("should compile template"); + assert_eq!( + slot.render_gam_unit_path("88059007", "news"), + "/88059007/autoblog/news" + ); + } + + #[test] + fn render_gam_unit_path_defaults_when_no_template() { + let mut slot = make_slot("sidebar", vec!["/*"]); + slot.gam_unit_path = None; + slot.compile_unit_template().expect("should compile (no template)"); + assert_eq!(slot.render_gam_unit_path("99999", "ignored"), "/99999/sidebar"); + } + + #[test] + fn render_gam_unit_path_uses_static_template_verbatim() { + let mut slot = make_slot("atf", vec!["/"]); + slot.gam_unit_path = Some("/99999/example/homepage".to_string()); + slot.compile_unit_template() + .expect("should compile static template"); + assert_eq!( + slot.render_gam_unit_path("99999", "news"), + "/99999/example/homepage" + ); + } + + #[test] + fn validate_runtime_requires_section_root_when_template_uses_section() { + let mut config = make_config_with_section_template(None); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + let err = config + .validate_runtime() + .expect_err("should require section_root"); + assert!(err.contains("section_root"), "error should mention section_root"); + } + + #[test] + fn validate_runtime_rejects_invalid_section_root() { + let mut config = make_config_with_section_template(Some("has space")); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + config + .validate_runtime() + .expect_err("should reject non [A-Za-z0-9_-] root"); + } + + #[test] + fn validate_runtime_accepts_section_template_with_valid_root() { + let mut config = make_config_with_section_template(Some("homepage")); + config.compile_slots(); + config + .compile_unit_templates() + .expect("templates should compile"); + config + .validate_runtime() + .expect("should accept valid section_root"); + } + + #[test] + fn compile_unit_templates_surfaces_parse_error() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/{bad}".to_string()); + config.compile_slots(); + config + .compile_unit_templates() + .expect_err("should surface unknown-placeholder error"); + } + #[test] fn validate_runtime_rejects_empty_div_id_override() { // An empty/whitespace div_id would resolve every slot to the first @@ -731,19 +916,19 @@ mod tests { slot.div_id = Some(String::new()); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "empty div_id override should fail validation" ); slot.div_id = Some(" ".to_string()); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "whitespace-only div_id override should fail validation" ); slot.div_id = Some("div-ad-x".to_string()); assert!( - slot.validate_runtime("1234").is_ok(), + slot.validate_runtime().is_ok(), "a concrete div_id override should pass validation" ); } @@ -755,31 +940,31 @@ mod tests { slot.floor_price = Some(-0.01); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "negative floor_price should fail validation" ); slot.floor_price = Some(f64::NAN); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "NaN floor_price should fail validation" ); slot.floor_price = Some(f64::INFINITY); assert!( - slot.validate_runtime("1234").is_err(), + slot.validate_runtime().is_err(), "infinite floor_price should fail validation" ); slot.floor_price = Some(0.0); assert!( - slot.validate_runtime("1234").is_ok(), + slot.validate_runtime().is_ok(), "zero floor_price should pass validation" ); slot.floor_price = None; assert!( - slot.validate_runtime("1234").is_ok(), + slot.validate_runtime().is_ok(), "absent floor_price should pass validation" ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 34909efe7..7edd35cf2 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4274,6 +4274,7 @@ mod tests { gam_network_id: "21765378893".to_string(), auction_timeout_ms: Some(500), price_granularity: PriceGranularity::Dense, + section_root: None, slot: Vec::new(), } } @@ -4295,6 +4296,7 @@ mod tests { .collect(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, } } @@ -4942,6 +4944,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } @@ -5444,6 +5447,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), + compiled_unit: None, }] } From 860ed0b263750be22f62fe2cdd13235726b00267 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 15:11:46 +0530 Subject: [PATCH 098/494] Render gam_unit_path template per request across initial and SPA paths --- crates/trusted-server-core/src/publisher.rs | 43 +++++++++++++++++---- crates/trusted-server-core/src/settings.rs | 9 ++++- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7edd35cf2..90bdc6c1d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1788,7 +1788,7 @@ pub async fn handle_publisher_request( settings .creative_opportunities .as_ref() - .map(|co_config| build_ad_slots_script(&matched_slots, co_config)) + .map(|co_config| build_ad_slots_script(&matched_slots, co_config, &request_path)) } else { None }; @@ -2201,11 +2201,18 @@ pub(crate) fn build_empty_bids_script() -> String { /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, /// `formats`, and `targeting`. -fn build_slot_json( +pub(crate) fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, ) -> serde_json::Value { - let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id); + // `{section}` derives from the same raw path `page_patterns` matched + // against; `section_root` covers the no-segment case (`/`). + let section = crate::creative_opportunities::derive_section( + request_path, + co_config.section_root.as_deref().unwrap_or_default(), + ); + let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, §ion); let div_id = slot.resolved_div_id(); let formats: Vec = slot .formats @@ -2233,10 +2240,11 @@ fn build_slot_json( pub(crate) fn build_ad_slots_script( matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, ) -> String { let slots: Vec = matched_slots .iter() - .map(|slot| build_slot_json(slot, co_config)) + .map(|slot| build_slot_json(slot, co_config, request_path)) .collect(); let json = serde_json::to_string(&slots) .expect("serde_json::to_string of Vec should be infallible"); @@ -2562,7 +2570,7 @@ pub async fn handle_page_bids( let slots_json: Vec = if ad_stack_enabled { matched_slots .iter() - .map(|slot| build_slot_json(slot, co_config)) + .map(|slot| build_slot_json(slot, co_config, &path_param)) .collect() } else { Vec::new() @@ -4331,7 +4339,7 @@ mod tests { fn ad_slots_script_contains_slot_data() { let slots = vec![make_slot()]; let config = make_config(); - let script = build_ad_slots_script(&slots, &config); + let script = build_ad_slots_script(&slots, &config, "/"); assert!( script.contains("window.tsjs=window.tsjs||{}"), "should initialise tsjs namespace" @@ -4352,7 +4360,7 @@ mod tests { fn ad_slots_script_is_xss_safe() { let slots = vec![make_slot()]; let config = make_config(); - let script = build_ad_slots_script(&slots, &config); + let script = build_ad_slots_script(&slots, &config, "/"); let inner = script .trim_start_matches(""); @@ -4360,6 +4368,27 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in script content"); } + #[test] + fn build_slot_json_renders_section_from_request_path() { + let mut config = make_config(); + config.section_root = Some("homepage".to_string()); + let mut slot = make_slot(); + slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + slot.compile_unit_template().expect("template should compile"); + + let news = crate::publisher::build_slot_json(&slot, &config, "/news/gm-cadillac"); + assert_eq!( + news["gam_unit_path"], "/21765378893/autoblog/news", + "section should derive from the first path segment" + ); + + let home = crate::publisher::build_slot_json(&slot, &config, "/"); + assert_eq!( + home["gam_unit_path"], "/21765378893/autoblog/homepage", + "root path should use section_root" + ); + } + #[test] fn bid_map_includes_nurl_and_burl() { let mut winning_bids = HashMap::new(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c49e99686..4514d12bd 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2077,6 +2077,13 @@ impl Settings { if let Some(co) = &mut self.creative_opportunities { co.compile_slots(); + // Parse `gam_unit_path` templates once here (mirrors the compiled + // glob cache) so request-time rendering is substitution-only. + co.compile_unit_templates().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity gam_unit_path template: {err}"), + }) + })?; // Slots flow into injected HTML/JS, provider payloads, and GPT // calls. Env/private config can bypass static review, so validate // the full runtime shape on every load path. @@ -5602,7 +5609,7 @@ gam_unit_path = "" page_patterns = ["/"] formats = [{ width = 300, height = 250 }] "#, - "resolved GAM unit path must not be empty", + "gam_unit_path template must not be empty", ); } From 21a35239b82ebe251ba2840075667f52b7fd3c20 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 15:13:39 +0530 Subject: [PATCH 099/494] Add spec and plan for per-section gam_unit_path --- .../2026-07-23-per-section-gam-unit-path.md | 746 ++++++++++++++++++ ...-07-23-per-section-gam-unit-path-design.md | 208 +++++ 2 files changed, 954 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md create mode 100644 docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md diff --git a/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md b/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md new file mode 100644 index 000000000..375795a6b --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md @@ -0,0 +1,746 @@ +# Per-Section `gam_unit_path` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `creative_opportunities.slot.gam_unit_path` a template with a +`{section}` placeholder derived from the request path, so one slot rule serves +all site sections instead of one rule per (slot × section). + +**Architecture:** Parse each slot's `gam_unit_path` into a cached template at +startup (alongside the existing compiled-glob cache); reject malformed templates +and a `{section}` template missing its `section_root`. At request time derive +`{section}` from the raw path (sanitized) and render the template inside +`build_slot_json`, which gains a `request_path` argument. Server-only — the +client keeps receiving a resolved `gam_unit_path` string, so no JS change. + +**Tech Stack:** Rust 2024, `trusted-server-core`. Tests via `cargo test_details` +(native host, `aarch64-apple-darwin`) for iteration and `cargo test-fastly` +(core + fastly on `wasm32-wasip1` via Viceroy) for the CI gate. + +**Spec:** `docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md` + +**Issue:** https://github.com/IABTechLab/trusted-server/issues/954 + +--- + +## File Structure + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` + - new: `UnitTemplatePart` enum, `parse_unit_template`, `sanitize_section`, + `derive_section` + - new on `CreativeOpportunitySlot`: `compiled_unit` field, + `compile_unit_template`, `render_gam_unit_path`, `template_uses_section` + - new on `CreativeOpportunitiesConfig`: `section_root` field, + `compile_unit_templates`; extend `validate_runtime` + - unit tests in the existing `#[cfg(test)] mod tests` +- Modify: `crates/trusted-server-core/src/publisher.rs` + - `build_slot_json` gains `request_path: &str`; renders via `render_gam_unit_path` + - `build_ad_slots_script` gains `request_path: &str`; threads it through + - `handle_page_bids` passes its normalized `path` to `build_slot_json` +- Modify: `crates/trusted-server-core/src/settings.rs` + - `prepare_runtime` calls `compile_unit_templates` and surfaces parse errors +- Modify: `docs/guide/configuration.md` (add creative_opportunities section) +- Modify: `trusted-server.example.toml` and the live autoblog config + +Notes on lifecycle: `page_patterns` inheritance is **out of scope** (sibling +issue). Templates are parsed at startup and cached with `#[serde(skip)]`, +mirroring the existing `compiled_patterns` field. + +--- + +## Task 1: Template parser + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Test: same file, `#[cfg(test)] mod tests` + +- [ ] **Step 1: Write the failing tests** + +Add to `mod tests`: + +```rust +#[test] +fn parse_unit_template_accepts_known_placeholders() { + let parts = parse_unit_template("/{network_id}/autoblog/{section}") + .expect("should parse valid template"); + assert_eq!(parts.len(), 4, "should split into literal+ph+literal+ph"); +} + +#[test] +fn parse_unit_template_accepts_static_path() { + let parts = parse_unit_template("/88059007/autoblog/homepage") + .expect("should parse a static path as a single literal"); + assert!( + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/88059007/autoblog/homepage"), + "should be one literal part" + ); +} + +#[test] +fn parse_unit_template_rejects_unknown_placeholder() { + let err = parse_unit_template("/{network_id}/{oops}").expect_err("should reject unknown placeholder"); + assert!(err.contains("oops"), "error should name the bad placeholder"); +} + +#[test] +fn parse_unit_template_rejects_unmatched_brace() { + parse_unit_template("/{network_id}/{section").expect_err("should reject unmatched '{'"); + parse_unit_template("/a}b").expect_err("should reject stray '}'"); +} + +#[test] +fn parse_unit_template_rejects_nested_brace() { + parse_unit_template("/{net{work}_id}").expect_err("should reject nested '{'"); +} + +#[test] +fn parse_unit_template_rejects_empty() { + parse_unit_template("").expect_err("should reject empty template"); +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::parse_unit_template` +Expected: FAIL — `cannot find function parse_unit_template` / `UnitTemplatePart`. + +- [ ] **Step 3: Implement the enum + parser** + +Add near the top of the module body (after imports): + +```rust +/// A single parsed segment of a `gam_unit_path` template. +#[derive(Debug, Clone)] +pub(crate) enum UnitTemplatePart { + /// Verbatim text between placeholders. + Literal(String), + /// `{network_id}` — replaced with the GAM network id. + NetworkId, + /// `{section}` — replaced with the request-derived section. + Section, + /// `{slot_id}` — replaced with the slot id. + SlotId, +} + +/// Parses a `gam_unit_path` template into an ordered list of parts. +/// +/// # Errors +/// +/// Returns an error string for an empty template, an unmatched or nested `{`, +/// a stray `}`, or an unknown placeholder name. +fn parse_unit_template(raw: &str) -> Result, String> { + if raw.is_empty() { + return Err("gam_unit_path template must not be empty".to_string()); + } + let mut parts = Vec::new(); + let mut literal = String::new(); + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '{' => { + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(std::mem::take(&mut literal))); + } + let mut name = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some('{') => { + return Err(format!("nested '{{' in template `{raw}`")); + } + Some(ch) => name.push(ch), + None => return Err(format!("unmatched '{{' in template `{raw}`")), + } + } + match name.as_str() { + "network_id" => parts.push(UnitTemplatePart::NetworkId), + "section" => parts.push(UnitTemplatePart::Section), + "slot_id" => parts.push(UnitTemplatePart::SlotId), + other => { + return Err(format!( + "unknown placeholder `{{{other}}}` in template `{raw}`" + )); + } + } + } + '}' => return Err(format!("stray '}}' in template `{raw}`")), + other => literal.push(other), + } + } + if !literal.is_empty() { + parts.push(UnitTemplatePart::Literal(literal)); + } + Ok(parts) +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::parse_unit_template` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative_opportunities.rs +git commit -m "Add gam_unit_path template parser" +``` + +--- + +## Task 2: Section derivation + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Test: same file + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn derive_section_uses_first_segment() { + assert_eq!(derive_section("/news", "home"), "news"); + assert_eq!(derive_section("/news/gm-cadillac", "home"), "news"); + assert_eq!(derive_section("/car-research/x", "home"), "car-research"); +} + +#[test] +fn derive_section_uses_root_when_no_segment() { + assert_eq!(derive_section("/", "homepage"), "homepage"); + assert_eq!(derive_section("///", "homepage"), "homepage"); +} + +#[test] +fn derive_section_sanitizes_unsafe_runs_to_single_underscore() { + // Not decoded: in "new%20s" only '%' is disallowed ('2' and '0' are + // alphanumeric), so it collapses to a single '_' -> "new_20s". This is + // exactly the no-decode contract: had we decoded, %20 would be a space and + // yield "new_s"; we do NOT decode. + assert_eq!(derive_section("/new%20s", "home"), "new_20s"); + // A run of disallowed chars collapses to one '_'. + assert_eq!(derive_section("/a..b", "home"), "a_b"); +} + +#[test] +fn derive_section_is_non_empty_for_all_disallowed_segment() { + assert_eq!(derive_section("/%%%/x", "home"), "_"); +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::derive_section` +Expected: FAIL — `cannot find function derive_section`. + +- [ ] **Step 3: Implement the two functions** + +```rust +/// Collapses each run of characters outside `[A-Za-z0-9_-]` to a single `_`. +/// +/// Returns a non-empty string for any non-empty input. +fn sanitize_section(segment: &str) -> String { + let mut out = String::with_capacity(segment.len()); + let mut in_bad_run = false; + for ch in segment.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + out.push(ch); + in_bad_run = false; + } else if !in_bad_run { + out.push('_'); + in_bad_run = true; + } + } + out +} + +/// Derives the `{section}` value from a request path. +/// +/// Uses the first non-empty path segment, sanitized to `[A-Za-z0-9_-]`. Falls +/// back to `section_root` when the path has no segment (`/`, repeated slashes). +/// The path is used **raw** (not percent-decoded) so this stays consistent with +/// how `page_patterns` glob-match the same path. +pub(crate) fn derive_section(path: &str, section_root: &str) -> String { + match path.split('/').find(|segment| !segment.is_empty()) { + Some(segment) => sanitize_section(segment), + None => section_root.to_string(), + } +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests::derive_section` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative_opportunities.rs +git commit -m "Add request-path section derivation" +``` + +--- + +## Task 3: Config field, template compile + render, startup validation + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Test: same file + +- [ ] **Step 1: Write the failing tests** + +```rust +// NOTE: the existing helper signature is `make_slot(id: &str, patterns: Vec<&str>)` +// (see creative_opportunities.rs:443) — pass `vec![...]`, not `&[...]`. +#[test] +fn render_gam_unit_path_substitutes_placeholders() { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + slot.compile_unit_template().expect("should compile template"); + assert_eq!( + slot.render_gam_unit_path("88059007", "news"), + "/88059007/autoblog/news" + ); +} + +#[test] +fn render_gam_unit_path_defaults_when_no_template() { + let mut slot = make_slot("sidebar", vec!["/*"]); + slot.gam_unit_path = None; + slot.compile_unit_template().expect("should compile (no template)"); + assert_eq!(slot.render_gam_unit_path("99999", "ignored"), "/99999/sidebar"); +} + +#[test] +fn render_gam_unit_path_uses_static_template_verbatim() { + let mut slot = make_slot("atf", vec!["/"]); + slot.gam_unit_path = Some("/99999/example/homepage".to_string()); + slot.compile_unit_template().expect("should compile static template"); + assert_eq!(slot.render_gam_unit_path("99999", "news"), "/99999/example/homepage"); +} + +#[test] +fn validate_runtime_requires_section_root_when_template_uses_section() { + let mut config = make_config_with_section_template(None); // section_root = None + config.compile_slots(); + config.compile_unit_templates().expect("templates compile"); + let err = config.validate_runtime().expect_err("should require section_root"); + assert!(err.contains("section_root"), "error should mention section_root"); +} + +#[test] +fn validate_runtime_rejects_invalid_section_root() { + let mut config = make_config_with_section_template(Some("has space")); + config.compile_slots(); + config.compile_unit_templates().expect("templates compile"); + config.validate_runtime().expect_err("should reject non [A-Za-z0-9_-] root"); +} + +#[test] +fn validate_runtime_accepts_section_template_with_valid_root() { + let mut config = make_config_with_section_template(Some("homepage")); + config.compile_slots(); + config.compile_unit_templates().expect("templates compile"); + config.validate_runtime().expect("should accept valid section_root"); +} + +#[test] +fn compile_unit_templates_surfaces_parse_error() { + let mut config = make_config_with_section_template(Some("home")); + config.slot[0].gam_unit_path = Some("/{bad}".to_string()); + config.compile_slots(); + config.compile_unit_templates().expect_err("should surface unknown-placeholder error"); +} +``` + +Add test helpers to `mod tests` if not present (adapt to the existing helper +style in this module): + +```rust +fn make_config_with_section_template(section_root: Option<&str>) -> CreativeOpportunitiesConfig { + let mut slot = make_slot("ad-header-0", vec!["/news/*"]); + slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + CreativeOpportunitiesConfig { + gam_network_id: "88059007".to_string(), + auction_timeout_ms: None, + price_granularity: PriceGranularity::default(), + section_root: section_root.map(str::to_string), + slot: vec![slot], + } +} +``` + +The `make_slot(id: &str, patterns: Vec<&str>)` helper **already exists** at +`creative_opportunities.rs:443` and constructs a `CreativeOpportunitySlot` via +struct-literal syntax. Because the struct uses `#[serde(deny_unknown_fields)]` +and the helper names every field explicitly, adding `compiled_unit` to the +struct makes this helper fail to compile until updated — see Step 3's helper-fix +sub-step. + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests` +Expected: FAIL — missing `section_root`, `compiled_unit`, `compile_unit_template`, +`render_gam_unit_path`, `compile_unit_templates`. + +- [ ] **Step 3: Add the field, cache, methods, and validation** + +On `CreativeOpportunitiesConfig` (add field): + +```rust +/// Value substituted for `{section}` when the request path has no first +/// segment (e.g. `/`). Required when any slot's `gam_unit_path` template +/// contains `{section}`. No default — a home-section name is publisher-specific. +#[serde(default)] +pub section_root: Option, +``` + +On `CreativeOpportunitySlot` (add cached template, parallel to `compiled_patterns`): + +```rust +/// Pre-parsed [`gam_unit_path`](Self::gam_unit_path) template, populated by +/// [`compile_unit_template`](Self::compile_unit_template) at startup. `None` +/// when the slot has no explicit `gam_unit_path` (uses the default path). +#[serde(skip, default)] +pub(crate) compiled_unit: Option>, +``` + +Slot methods: + +```rust +/// Parses [`gam_unit_path`](Self::gam_unit_path) into [`compiled_unit`](Self::compiled_unit). +/// +/// # Errors +/// +/// Returns an error string when the template is malformed (see +/// [`parse_unit_template`]). +pub fn compile_unit_template(&mut self) -> Result<(), String> { + self.compiled_unit = match &self.gam_unit_path { + Some(raw) => Some(parse_unit_template(raw).map_err(|e| format!("slot `{}`: {e}", self.id))?), + None => None, + }; + Ok(()) +} + +/// Renders the resolved GAM unit path for a given network id and section. +/// +/// Uses the parsed template when present, otherwise the default +/// `//`. +#[must_use] +pub fn render_gam_unit_path(&self, gam_network_id: &str, section: &str) -> String { + match &self.compiled_unit { + Some(parts) => parts + .iter() + .map(|part| match part { + UnitTemplatePart::Literal(s) => s.as_str(), + UnitTemplatePart::NetworkId => gam_network_id, + UnitTemplatePart::Section => section, + UnitTemplatePart::SlotId => self.id.as_str(), + }) + .collect(), + None => format!("/{}/{}", gam_network_id, self.id), + } +} + +/// Returns `true` if this slot's compiled template contains `{section}`. +#[must_use] +pub(crate) fn template_uses_section(&self) -> bool { + self.compiled_unit + .as_ref() + .is_some_and(|parts| parts.iter().any(|p| matches!(p, UnitTemplatePart::Section))) +} +``` + +On `CreativeOpportunitiesConfig` (compile all templates + extend validation): + +```rust +/// Parse every slot's `gam_unit_path` template. Call once after deserialization. +/// +/// # Errors +/// +/// Returns an error string when any slot's template is malformed. +pub fn compile_unit_templates(&mut self) -> Result<(), String> { + for slot in &mut self.slot { + slot.compile_unit_template()?; + } + Ok(()) +} +``` + +In `validate_runtime`, after the existing per-slot loop, add: + +```rust +if self.slot.iter().any(CreativeOpportunitySlot::template_uses_section) { + match self.section_root.as_deref() { + Some(root) + if !root.is_empty() + && root.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => {} + _ => { + return Err( + "section_root is required and must match [A-Za-z0-9_-]+ when a \ + gam_unit_path template uses {section}" + .to_string(), + ); + } + } +} +``` + +Remove the old path-render emptiness check in `validate_runtime` +(the block calling `resolved_gam_unit_path(...).trim().is_empty()`); malformed or +empty templates are now caught at parse time by `compile_unit_templates`, and a +rendered result is non-empty by construction. + +**Update the existing test helper (required — adding `compiled_unit` breaks it):** +Add `compiled_unit: None` to the `CreativeOpportunitySlot` struct-literal in +`make_slot` at `crates/trusted-server-core/src/creative_opportunities.rs:443`. +The struct uses `#[serde(deny_unknown_fields)]` and the helper names every field, +so a missing field is a compile error, not a `#[serde(default)]` fill-in. + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core creative_opportunities::tests` +Expected: PASS (Task 1–3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/creative_opportunities.rs +git commit -m "Add section_root, unit-template compile/render, and startup validation" +``` + +--- + +## Task 4: Render at request time (thread the path through publisher.rs) + +**Files:** + +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` (remove now-unused `resolved_gam_unit_path`, or keep if other callers remain — grep first) +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Test: `crates/trusted-server-core/src/publisher.rs` `#[cfg(test)] mod tests` + +- [ ] **Step 0: Update publisher.rs struct-literal test helpers (required — new fields break them)** + +Adding `section_root` to `CreativeOpportunitiesConfig` and `compiled_unit` to +`CreativeOpportunitySlot` breaks every hand-built literal in `publisher.rs` +tests. Add the new fields to each: + +- `crates/trusted-server-core/src/publisher.rs:4272` — `make_config()`: add `section_root: None`. +- `crates/trusted-server-core/src/publisher.rs:4282` — `make_slot()`: add `compiled_unit: None`. +- `crates/trusted-server-core/src/publisher.rs:4931` — `article_slot()`: add `compiled_unit: None`. +- `crates/trusted-server-core/src/publisher.rs:5433` — `article_slot()` (second module): add `compiled_unit: None`. + +Run: `cargo test_details -p trusted-server-core publisher:: --no-run` +Expected: compiles (no `missing field` errors) before writing the new test. + +- [ ] **Step 1: Write the failing test (equivalence + per-section)** + +In `publisher.rs` tests, add (adapt to the existing test helpers/config builders +in that module): + +```rust +#[test] +fn build_slot_json_renders_section_from_request_path() { + let config = creative_opportunities_config_with_template(); // gam_unit_path = "/{network_id}/autoblog/{section}", section_root = "homepage" + let slot = &config.slot[0]; + + let news = build_slot_json(slot, &config, "/news/gm-cadillac"); + assert_eq!(news["gam_unit_path"], "/88059007/autoblog/news"); + + let home = build_slot_json(slot, &config, "/"); + assert_eq!(home["gam_unit_path"], "/88059007/autoblog/homepage"); +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test_details -p trusted-server-core publisher::tests::build_slot_json_renders_section` +Expected: FAIL — `build_slot_json` takes 2 args / wrong unit value. + +- [ ] **Step 3: Thread `request_path` and render** + +In `build_slot_json` (`crates/trusted-server-core/src/publisher.rs` ~2204): + +```rust +fn build_slot_json( + slot: &crate::creative_opportunities::CreativeOpportunitySlot, + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, +) -> serde_json::Value { + let section = crate::creative_opportunities::derive_section( + request_path, + co_config.section_root.as_deref().unwrap_or_default(), + ); + let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, §ion); + // ...rest unchanged (div_id, formats, targeting, json!)... +} +``` + +In `build_ad_slots_script` (~2233) add `request_path: &str` and pass it: + +```rust +pub(crate) fn build_ad_slots_script( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, + request_path: &str, +) -> String { + let slots: Vec = matched_slots + .iter() + .map(|slot| build_slot_json(slot, co_config, request_path)) + .collect(); + // ...unchanged... +} +``` + +At the initial-render caller (~publisher.rs:1791) pass `&request_path`: + +```rust +.map(|co_config| build_ad_slots_script(&matched_slots, co_config, &request_path)) +``` + +In `handle_page_bids` (~2562) pass the already-normalized path +(`path_param` / the value from `normalize_page_bids_path`) to `build_slot_json`: + +```rust +.map(|slot| build_slot_json(slot, co_config, &path_param)) +``` + +Update any existing `build_ad_slots_script(...)` / `build_slot_json(...)` test +call sites in `publisher.rs` to pass a path argument (e.g. `"/"`). + +- [ ] **Step 4: Update `settings.rs::prepare_runtime`** + +In `crates/trusted-server-core/src/settings.rs` (~2078), compile templates and +surface parse errors: + +```rust +if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + co.compile_unit_templates().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity gam_unit_path template: {err}"), + }) + })?; + co.validate_runtime().map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid creative opportunity slot config: {err}"), + }) + })?; +} +``` + +- [ ] **Step 5: Run tests, verify they pass** + +Run: `cargo test_details -p trusted-server-core publisher::tests` +Expected: PASS. + +- [ ] **Step 6: Fix the existing empty-`gam_unit_path` settings test if needed** + +`settings.rs::settings_rejects_creative_opportunity_slot_with_empty_gam_unit_path` +now fails at template-parse (empty template) rather than the render check. Verify +it still asserts rejection; update the expected error substring if it pins a +message. + +Run: `cargo test_details -p trusted-server-core settings::` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/creative_opportunities.rs crates/trusted-server-core/src/settings.rs +git commit -m "Render gam_unit_path template per request across initial and SPA paths" +``` + +--- + +## Task 5: Docs + config + +**Files:** + +- Modify: `docs/guide/configuration.md` +- Modify: `trusted-server.example.toml` +- Modify: the live autoblog `trusted-server.toml` (operator-owned, gitignored — update locally, do not commit) + +- [ ] **Step 1: Add a creative_opportunities section to configuration.md** + +Cover: the placeholder set (`{network_id}`, `{section}`, `{slot_id}`); section +derivation (first path segment, sanitized to `[A-Za-z0-9_-]`, raw/undecoded); +`section_root` requirement and validation; behavior on an unmatched route (no +slot, template never rendered); back-compat (static path used verbatim; no +`gam_unit_path` → `//`). Use fictional values +(`example.com`, network `99999`) per the repo's docs rule. + +- [ ] **Step 2: Update `trusted-server.example.toml`** + +Show one templated slot with `section_root` and a `{section}` `gam_unit_path`, +using fictional values. + +- [ ] **Step 3: Docs format check** + +Run: `cd docs && npm run format` +Expected: no diff / formatting clean. + +- [ ] **Step 4: Commit** + +```bash +git add docs/guide/configuration.md trusted-server.example.toml +git commit -m "Document per-section gam_unit_path templating" +``` + +--- + +## Task 6: Full verification (CI gate) + +- [ ] **Step 1: Format** + +Run: `cargo fmt --all -- --check` +Expected: clean. + +- [ ] **Step 2: Core + Fastly tests under Viceroy (full module, not filtered)** + +Run: `cargo test-fastly` +Expected: PASS. (Runs the full creative_opportunities + publisher test modules on +`wasm32-wasip1`; a format-changing edit can hide later failures when filtered, so +run the whole suite here.) + +- [ ] **Step 3: Other adapters (no behavior change expected, guard against signature breaks)** + +Run: `cargo test-axum && cargo test-cloudflare && cargo test-spin` +Expected: PASS. + +- [ ] **Step 4: Clippy across adapter targets** + +Run: `cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm` +Expected: no warnings. + +- [ ] **Step 5: JS unaffected (sanity)** + +Run: `cd crates/trusted-server-js/lib && npx vitest run` +Expected: PASS (no JS change; confirms wire shape unbroken). + +- [ ] **Step 6: Final commit if any fixups** + +```bash +git add -A +git commit -m "Fix clippy/fmt for per-section gam_unit_path" +``` + +--- + +## Acceptance criteria mapping + +- **N slots × M sections without N×M rules** — Task 1–4 (one templated slot rule + serves all sections). +- **Resolution tested (`/`, single/multi-segment, no-match, encoded)** — Task 2 + tests + Task 4 equivalence + the unmatched-route case (no slot matched → no + `build_slot_json` call; covered by existing `match_slots` empty tests). +- **Existing static configs unchanged** — Task 3 `render_gam_unit_path` verbatim + - default tests. +- **Startup catches empty/unknown/malformed template + missing/invalid + `section_root`** — Task 1 + Task 3 validation tests. +- **`{section}` sanitized, raw path** — Task 2 tests. +- **Documented** — Task 5. diff --git a/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md new file mode 100644 index 000000000..39e25d42f --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md @@ -0,0 +1,208 @@ +# Per-Section `gam_unit_path` Design + +**Date:** 2026-07-23 + +**Status:** Proposed + +**Issue:** [IABTechLab/trusted-server#954](https://github.com/IABTechLab/trusted-server/issues/954) + +## Summary + +`creative_opportunities.slot.gam_unit_path` is a static string, so a publisher +whose GAM ad unit varies by site section cannot express that in one rule. The +only way to model it today is one slot rule per (slot × section), which +multiplies out fast: 3 slots across 10 sections needs 30 near-identical rules. + +This design makes `gam_unit_path` a **template** with a small, fixed placeholder +set — `{network_id}`, `{section}`, `{slot_id}` — where `{section}` is derived +from the request path at render time. One slot rule then covers all sections. + +The derivation policy that matters (`{section}` for the site root) lives in +config via a required `section_root`, not in core — honoring the issue's +constraint that the URL→section convention is publisher-specific. + +Scope is deliberately narrow: **only** `gam_unit_path` templating. Sharing +`page_patterns`/`gam_unit_path` defaults across slots is a related but distinct +duplication problem, tracked as a sibling issue, not built here. + +## Goals + +1. A publisher with N slots across M sections expresses per-section ad units + without N×M rules. +2. `{section}` is derived from the request path with a config-supplied value for + the site root; no URL convention is hardcoded in core. +3. Existing static `gam_unit_path` configs keep working, byte-for-byte + unchanged. +4. Startup rejects unresolvable configuration: unknown placeholders, malformed + templates, and a `{section}` template missing its `section_root`. +5. Resolution is covered by tests including `/`, single- and multi-segment + paths, unsafe/encoded segments, and paths matching no slot. +6. Documented in `docs/guide/configuration.md`, which currently has no + creative_opportunities section. + +## Non-goals + +Documented here so onboarding publishers know the boundary. Each is an additive +extension that does **not** change the config shape below. + +1. **Locale offset** — deriving `{section}` from a segment other than the first + (e.g. `/en/news` → `news`). `{section}` is the first path segment. A + `section_segment` index knob can be added later. +2. **Full-path mirror** — `{section}` spanning multiple segments (`/a/b` → + `a/b`). Real GAM trees bucket by section, not per-article, so this is rare; + use a static per-slot `gam_unit_path` for the exception. +3. **Named per-section overrides** — mapping an irregular section to a renamed + unit (`/reviews` → `editorial/reviews-v2`). Set that one slot's + `gam_unit_path` explicitly, or add named overrides later. +4. **Host- or query-derived sections** — path-only. Out of scope entirely. +5. **Slot-defaults inheritance** — sharing `page_patterns`/`gam_unit_path` at + the `[creative_opportunities]` level. Separate issue; has a startup-lifecycle + concern this design intentionally avoids. + +## Background: how `gam_unit_path` is used + +- `gam_unit_path` is **client-side only**. The resolved string reaches + `googletag.defineSlot(path, sizes, div)` in + `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`. It is **not** in + the OpenRTB bid request — `CreativeOpportunitySlot::to_ad_slot` never emits it. + Therefore this change is **server-only; no JS wire change**. The client keeps + receiving a resolved `gam_unit_path` string. +- Today's resolver is literal-or-default and path-independent + (`crates/trusted-server-core/src/creative_opportunities.rs`): + + ```rust + pub fn resolved_gam_unit_path(&self, gam_network_id: &str) -> String { + self.gam_unit_path + .clone() + .unwrap_or_else(|| format!("/{}/{}", gam_network_id, self.id)) + } + ``` + +- The value is emitted in `build_slot_json` + (`crates/trusted-server-core/src/publisher.rs`), shared by two paths: + - initial render via `build_ad_slots_script` (called where `request_path` is + in scope); + - SPA navigation via `handle_page_bids` (has the normalized `path` param). + + Neither currently passes the path into `build_slot_json`. + +## Design + +### Config shape + +```toml +[creative_opportunities] +gam_network_id = "88059007" +auction_timeout_ms = 2000 +price_granularity = "dense" +section_root = "homepage" # required when a template uses {section} + +[[creative_opportunities.slot]] +id = "ad-header-0" +gam_unit_path = "/{network_id}/autoblog/{section}" +page_patterns = ["/", "/news/*", "/reviews/*", "/deals/*"] +formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }] +[creative_opportunities.slot.providers.prebid] +bidders = {} +``` + +### Placeholders + +| placeholder | resolves to | +| -------------- | ----------------------------------------------------- | +| `{network_id}` | `gam_network_id` | +| `{slot_id}` | slot `id` | +| `{section}` | first path segment; `section_root` when path has none | + +### Resolution model + +```text +startup (prepare_runtime, once): + for each slot: + parse slot.gam_unit_path (if Some) into a template: + reject unknown placeholder, unmatched/nested brace, empty template + cache the parsed template (serde-skipped, like compiled_patterns) + if any slot's template contains {section}: + require section_root present AND matching ^[A-Za-z0-9_-]+$ + +request (per matched slot, path known): + if slot has a parsed template: + section = first non-empty segment of the RAW path, + runs of [^A-Za-z0-9_-] replaced with a single '_'; + section_root when the path has no segment ("/", repeated slashes) + render template + else: + "/{network_id}/{slot_id}" # existing default (back-compat) +``` + +### Section derivation rules (deterministic) + +- Extract the **first non-empty** path segment. +- Replace each run of disallowed characters (`[^A-Za-z0-9_-]`) with a single + `_`. Guarantees a non-empty result for any non-empty segment. Because the path + is **not** decoded, `new%20s` → `new_20s` (only `%` is disallowed; `2` and `0` + are alphanumeric) — never silently `news`, and never the decoded `new_s`. +- Use `section_root` **only** when there is no segment (`/`, repeated slashes). +- Derive from the **raw, undecoded** path — the same string `page_patterns` + glob-match against — so matching and derivation never disagree. Percent-encoded + segments are **not** decoded. +- `section_root` validated at startup: non-empty, entirely `[A-Za-z0-9_-]`. + +### Back-compat + +- No template placeholders in a slot's `gam_unit_path` → used verbatim. +- No `gam_unit_path` set on a slot → `/{network_id}/{slot_id}` (unchanged). +- A config with no `{section}` anywhere never requires `section_root`. + +### Validation moves from render to parse + +`validate_runtime` currently calls `resolved_gam_unit_path` and rejects an empty +result. That check becomes path-dependent under templating, so it is replaced by +**startup template validation**: the template parses, all placeholders are +known, and `section_root` is present when `{section}` is used. The rendered +result is non-empty by construction (literals plus non-empty substitutions, or +the `/{network_id}/{slot_id}` default), so no per-request emptiness check is +needed. + +## Alternatives considered + +- **Named sections** (`[section.NAME]` blocks carrying patterns + unit): more + general (expresses irregular units) but forces enumerating every section, and + centralizes patterns — a bigger change that overlaps the deferred + slot-defaults concern. Rejected as the base; the `unit`-override variant is a + possible future extension. +- **Explicit `unit_by_pattern` map per slot** (issue option 2): fully + data-driven but repeats the section→unit table inside every slot, so adding a + section still edits all N slots. Rejected. +- **Hardcoded first-segment derivation** (issue option 3, literal): smallest, + but bakes one site's URL convention into core, which the issue forbids. The + chosen design keeps the one publisher-specific knob (`section_root`) in config. + +## Risks + +- **Client-influenced path.** `{section}` is derived from a request path the + client controls (especially the SPA `path` param). Mitigated by: sanitizing to + `[A-Za-z0-9_-]`; deriving only for paths that already matched a slot's + `page_patterns`; and the fact that `gam_unit_path` is not in the bid request, + so a crafted section only affects the caller's own `defineSlot`. +- **Two render paths drift.** Initial-render and SPA must produce identical + units for the same path. Covered by an equivalence test. + +## Acceptance criteria + +- [ ] N slots × M sections without N×M rules. +- [ ] Resolution tested: `/`, single-segment, multi-segment, no-match, encoded + segment. +- [ ] Existing static `gam_unit_path` configs unchanged. +- [ ] `validate()` (startup) catches empty/unknown/malformed template and a + `{section}` template with missing/invalid `section_root`. +- [ ] `{section}` sanitized to `[A-Za-z0-9_-]`, derived from the raw path. +- [ ] Documented in `docs/guide/configuration.md`, including unmatched-route + behavior and the no-decode rule; example and live autoblog configs updated. + +## Sibling issue (not built here) + +"creative_opportunities: support shared slot defaults for `page_patterns` and +`gam_unit_path`." Inheritance of `page_patterns` must materialize onto each slot +at startup **before** `compile_slots()` (because `match_slots` never sees the +top-level config), which is the lifecycle subtlety this scoped design avoids. From 070fd944b6cb08a667d885fc1277adb4aaa33ca3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 15:13:39 +0530 Subject: [PATCH 100/494] Document per-section gam_unit_path templating --- docs/guide/configuration.md | 75 +++++++++++++++++++++++++++++++++++++ trusted-server.example.toml | 22 +++++++++++ 2 files changed, 97 insertions(+) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b975590c6..f5b778a15 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1229,6 +1229,81 @@ TRUSTED_SERVER__AUCTION__TIMEOUT_MS=2000 TRUSTED_SERVER__AUCTION__CREATIVE_STORE=creative_store ``` +## Creative Opportunities Configuration + +### `[creative_opportunities]` + +Defines the ad slots the trusted server offers on a page: which pages each slot +appears on (`page_patterns`), its supported sizes (`formats`), and the GAM ad +unit it maps to (`gam_unit_path`). + +```toml +[creative_opportunities] +gam_network_id = "123456789" +price_granularity = "dense" + +# Shared placeholder value for the site root ("/") — see {section} below. +section_root = "home" + +[[creative_opportunities.slot]] +id = "ad-header" +gam_unit_path = "/{network_id}/example/{section}" +page_patterns = ["/", "/news/*", "/reviews/*"] +formats = [{ width = 728, height = 90 }] +``` + +### `gam_unit_path` templating + +`gam_unit_path` is a template. A publisher whose ad unit varies by site section +expresses that in **one** slot rule instead of one rule per (slot × section). + +Supported placeholders: + +| Placeholder | Resolves to | +| -------------- | -------------------------------------------------------------- | +| `{network_id}` | `gam_network_id` | +| `{slot_id}` | the slot's `id` | +| `{section}` | first path segment of the request (see derivation rules below) | + +A template with **no** placeholders is used verbatim. A slot with **no** +`gam_unit_path` falls back to `//`. Both preserve the +pre-templating behavior, so existing static configs are unchanged. + +### `{section}` derivation + +`{section}` is derived from the request path at request time: + +- It is the **first non-empty path segment**. `/news/article-123` → `news`. +- It is sanitized: each run of characters outside `[A-Za-z0-9_-]` becomes a + single `_`. +- The path is used **raw — it is not percent-decoded**. So `/new%20s` → + `new_20s` (only `%` is disallowed; `2` and `0` are kept), never the decoded + `new_s`. This keeps `{section}` consistent with how `page_patterns` match the + same raw path. +- When the path has no segment (`/`, or repeated slashes), `{section}` is + `section_root`. + +`section_root` is **required** whenever any slot's template uses `{section}`, +and must match `[A-Za-z0-9_-]+`. There is no default: the home-section name is +publisher-specific, so the URL→section convention lives in config, not core. +Startup fails if `{section}` is used without a valid `section_root`. + +Example resolution for `gam_unit_path = "/{network_id}/example/{section}"` with +`gam_network_id = "123456789"` and `section_root = "home"`: + +| Request path | `gam_unit_path` | +| --------------- | ---------------------------- | +| `/` | `/123456789/example/home` | +| `/news` | `/123456789/example/news` | +| `/news/article` | `/123456789/example/news` | +| `/reviews/x` | `/123456789/example/reviews` | + +An **unmatched route** — a path matched by no slot's `page_patterns` — produces +no slot at all, so no template is rendered for it. + +Startup validation rejects a malformed template: an unknown placeholder (e.g. +`{oops}`), an unmatched or nested `{`, a stray `}`, or an empty `gam_unit_path`. + ## Fastly Runtime Config Store After the EdgeZero cutover, the Fastly adapter always dispatches through the diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 26d95d681..e2f8994f6 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -157,7 +157,29 @@ gam_network_id = "123456789" auction_timeout_ms = 500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS price_granularity = "dense" +# `gam_unit_path` may be a template. Supported placeholders: +# {network_id} -> gam_network_id +# {slot_id} -> the slot's id +# {section} -> first path segment of the request, sanitized to +# [A-Za-z0-9_-]; `section_root` below is used for "/". +# A template with no placeholders (or an absent gam_unit_path) keeps the old +# behavior: verbatim path, or the default `//`. +# +# `section_root` is REQUIRED when any slot's template uses {section}. There is no +# default — the home-section name is publisher-specific. Must be [A-Za-z0-9_-]+. +section_root = "home" + # No slot templates are enabled in the checked-in default config. Add # `[[creative_opportunities.slot]]` entries via private config or override the # entire array via: # TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' +# +# Example templated slot (one rule serves every section): +# [[creative_opportunities.slot]] +# id = "ad-header" +# gam_unit_path = "/{network_id}/example/{section}" +# page_patterns = ["/", "/news/*", "/reviews/*"] +# formats = [{ width = 728, height = 90 }] +# "/" -> /123456789/example/home +# "/news/x" -> /123456789/example/news +# "/reviews/y" -> /123456789/example/reviews From 20f5f8e3a6b4ac4d4e7711f1f5c0d7ee08bea3a2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 23 Jul 2026 15:23:17 +0530 Subject: [PATCH 101/494] Use fictional example values in tests and docs --- .../src/creative_opportunities.rs | 50 ++++++++++++------- crates/trusted-server-core/src/publisher.rs | 17 ++++--- .../2026-07-23-per-section-gam-unit-path.md | 32 ++++++------ ...-07-23-per-section-gam-unit-path-design.md | 6 +-- 4 files changed, 61 insertions(+), 44 deletions(-) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 59ce6d46b..cc1ac67a3 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -325,7 +325,10 @@ impl CreativeOpportunitySlot { if let Some(raw) = &self.gam_unit_path && raw.trim().is_empty() { - return Err(format!("slot `{}` gam_unit_path must not be empty", self.id)); + return Err(format!( + "slot `{}` gam_unit_path must not be empty", + self.id + )); } Ok(()) @@ -750,17 +753,17 @@ mod tests { #[test] fn parse_unit_template_accepts_known_placeholders() { - let parts = parse_unit_template("/{network_id}/autoblog/{section}") + let parts = parse_unit_template("/{network_id}/example/{section}") .expect("should parse valid template"); assert_eq!(parts.len(), 4, "should split into literal+ph+literal+ph"); } #[test] fn parse_unit_template_accepts_static_path() { - let parts = parse_unit_template("/88059007/autoblog/homepage") + let parts = parse_unit_template("/99999/example/homepage") .expect("should parse a static path as a single literal"); assert!( - matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/88059007/autoblog/homepage"), + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/99999/example/homepage"), "should be one literal part" ); } @@ -769,7 +772,10 @@ mod tests { fn parse_unit_template_rejects_unknown_placeholder() { let err = parse_unit_template("/{network_id}/{oops}") .expect_err("should reject unknown placeholder"); - assert!(err.contains("oops"), "error should name the bad placeholder"); + assert!( + err.contains("oops"), + "error should name the bad placeholder" + ); } #[test] @@ -791,8 +797,8 @@ mod tests { #[test] fn derive_section_uses_first_segment() { assert_eq!(derive_section("/news", "home"), "news"); - assert_eq!(derive_section("/news/gm-cadillac", "home"), "news"); - assert_eq!(derive_section("/car-research/x", "home"), "car-research"); + assert_eq!(derive_section("/news/article-123", "home"), "news"); + assert_eq!(derive_section("/my-section/x", "home"), "my-section"); } #[test] @@ -817,11 +823,13 @@ mod tests { assert_eq!(derive_section("/%%%/x", "home"), "_"); } - fn make_config_with_section_template(section_root: Option<&str>) -> CreativeOpportunitiesConfig { + fn make_config_with_section_template( + section_root: Option<&str>, + ) -> CreativeOpportunitiesConfig { let mut slot = make_slot("ad-header-0", vec!["/news/*"]); - slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); CreativeOpportunitiesConfig { - gam_network_id: "88059007".to_string(), + gam_network_id: "99999".to_string(), auction_timeout_ms: None, price_granularity: PriceGranularity::default(), section_root: section_root.map(str::to_string), @@ -832,11 +840,12 @@ mod tests { #[test] fn render_gam_unit_path_substitutes_placeholders() { let mut slot = make_slot("ad-header-0", vec!["/news/*"]); - slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); - slot.compile_unit_template().expect("should compile template"); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template() + .expect("should compile template"); assert_eq!( - slot.render_gam_unit_path("88059007", "news"), - "/88059007/autoblog/news" + slot.render_gam_unit_path("99999", "news"), + "/99999/example/news" ); } @@ -844,8 +853,12 @@ mod tests { fn render_gam_unit_path_defaults_when_no_template() { let mut slot = make_slot("sidebar", vec!["/*"]); slot.gam_unit_path = None; - slot.compile_unit_template().expect("should compile (no template)"); - assert_eq!(slot.render_gam_unit_path("99999", "ignored"), "/99999/sidebar"); + slot.compile_unit_template() + .expect("should compile (no template)"); + assert_eq!( + slot.render_gam_unit_path("99999", "ignored"), + "/99999/sidebar" + ); } #[test] @@ -870,7 +883,10 @@ mod tests { let err = config .validate_runtime() .expect_err("should require section_root"); - assert!(err.contains("section_root"), "error should mention section_root"); + assert!( + err.contains("section_root"), + "error should mention section_root" + ); } #[test] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 90bdc6c1d..e8de8582c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4304,7 +4304,7 @@ mod tests { .collect(), providers: Default::default(), compiled_patterns: Vec::new(), - compiled_unit: None, + compiled_unit: None, } } @@ -4373,18 +4373,19 @@ mod tests { let mut config = make_config(); config.section_root = Some("homepage".to_string()); let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); - slot.compile_unit_template().expect("template should compile"); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); + slot.compile_unit_template() + .expect("template should compile"); - let news = crate::publisher::build_slot_json(&slot, &config, "/news/gm-cadillac"); + let news = crate::publisher::build_slot_json(&slot, &config, "/news/article-123"); assert_eq!( - news["gam_unit_path"], "/21765378893/autoblog/news", + news["gam_unit_path"], "/21765378893/example/news", "section should derive from the first path segment" ); let home = crate::publisher::build_slot_json(&slot, &config, "/"); assert_eq!( - home["gam_unit_path"], "/21765378893/autoblog/homepage", + home["gam_unit_path"], "/21765378893/example/homepage", "root path should use section_root" ); } @@ -4973,7 +4974,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), - compiled_unit: None, + compiled_unit: None, }] } @@ -5476,7 +5477,7 @@ mod tests { targeting: Default::default(), providers: Default::default(), compiled_patterns: Vec::new(), - compiled_unit: None, + compiled_unit: None, }] } diff --git a/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md b/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md index 375795a6b..cbd4a2ed1 100644 --- a/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md +++ b/docs/superpowers/plans/2026-07-23-per-section-gam-unit-path.md @@ -40,7 +40,7 @@ client keeps receiving a resolved `gam_unit_path` string, so no JS change. - Modify: `crates/trusted-server-core/src/settings.rs` - `prepare_runtime` calls `compile_unit_templates` and surfaces parse errors - Modify: `docs/guide/configuration.md` (add creative_opportunities section) -- Modify: `trusted-server.example.toml` and the live autoblog config +- Modify: `trusted-server.example.toml` and the live example config Notes on lifecycle: `page_patterns` inheritance is **out of scope** (sibling issue). Templates are parsed at startup and cached with `#[serde(skip)]`, @@ -62,17 +62,17 @@ Add to `mod tests`: ```rust #[test] fn parse_unit_template_accepts_known_placeholders() { - let parts = parse_unit_template("/{network_id}/autoblog/{section}") + let parts = parse_unit_template("/{network_id}/example/{section}") .expect("should parse valid template"); assert_eq!(parts.len(), 4, "should split into literal+ph+literal+ph"); } #[test] fn parse_unit_template_accepts_static_path() { - let parts = parse_unit_template("/88059007/autoblog/homepage") + let parts = parse_unit_template("/99999/example/homepage") .expect("should parse a static path as a single literal"); assert!( - matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/88059007/autoblog/homepage"), + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/99999/example/homepage"), "should be one literal part" ); } @@ -202,8 +202,8 @@ git commit -m "Add gam_unit_path template parser" #[test] fn derive_section_uses_first_segment() { assert_eq!(derive_section("/news", "home"), "news"); - assert_eq!(derive_section("/news/gm-cadillac", "home"), "news"); - assert_eq!(derive_section("/car-research/x", "home"), "car-research"); + assert_eq!(derive_section("/news/article-123", "home"), "news"); + assert_eq!(derive_section("/my-section/x", "home"), "my-section"); } #[test] @@ -298,11 +298,11 @@ git commit -m "Add request-path section derivation" #[test] fn render_gam_unit_path_substitutes_placeholders() { let mut slot = make_slot("ad-header-0", vec!["/news/*"]); - slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); slot.compile_unit_template().expect("should compile template"); assert_eq!( - slot.render_gam_unit_path("88059007", "news"), - "/88059007/autoblog/news" + slot.render_gam_unit_path("99999", "news"), + "/99999/example/news" ); } @@ -362,9 +362,9 @@ style in this module): ```rust fn make_config_with_section_template(section_root: Option<&str>) -> CreativeOpportunitiesConfig { let mut slot = make_slot("ad-header-0", vec!["/news/*"]); - slot.gam_unit_path = Some("/{network_id}/autoblog/{section}".to_string()); + slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); CreativeOpportunitiesConfig { - gam_network_id: "88059007".to_string(), + gam_network_id: "99999".to_string(), auction_timeout_ms: None, price_granularity: PriceGranularity::default(), section_root: section_root.map(str::to_string), @@ -545,14 +545,14 @@ in that module): ```rust #[test] fn build_slot_json_renders_section_from_request_path() { - let config = creative_opportunities_config_with_template(); // gam_unit_path = "/{network_id}/autoblog/{section}", section_root = "homepage" + let config = creative_opportunities_config_with_template(); // gam_unit_path = "/{network_id}/example/{section}", section_root = "homepage" let slot = &config.slot[0]; - let news = build_slot_json(slot, &config, "/news/gm-cadillac"); - assert_eq!(news["gam_unit_path"], "/88059007/autoblog/news"); + let news = build_slot_json(slot, &config, "/news/article-123"); + assert_eq!(news["gam_unit_path"], "/99999/example/news"); let home = build_slot_json(slot, &config, "/"); - assert_eq!(home["gam_unit_path"], "/88059007/autoblog/homepage"); + assert_eq!(home["gam_unit_path"], "/99999/example/homepage"); } ``` @@ -663,7 +663,7 @@ git commit -m "Render gam_unit_path template per request across initial and SPA - Modify: `docs/guide/configuration.md` - Modify: `trusted-server.example.toml` -- Modify: the live autoblog `trusted-server.toml` (operator-owned, gitignored — update locally, do not commit) +- Modify: the live example `trusted-server.toml` (operator-owned, gitignored — update locally, do not commit) - [ ] **Step 1: Add a creative_opportunities section to configuration.md** diff --git a/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md index 39e25d42f..b18c1bcb0 100644 --- a/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md +++ b/docs/superpowers/specs/2026-07-23-per-section-gam-unit-path-design.md @@ -92,14 +92,14 @@ extension that does **not** change the config shape below. ```toml [creative_opportunities] -gam_network_id = "88059007" +gam_network_id = "99999" auction_timeout_ms = 2000 price_granularity = "dense" section_root = "homepage" # required when a template uses {section} [[creative_opportunities.slot]] id = "ad-header-0" -gam_unit_path = "/{network_id}/autoblog/{section}" +gam_unit_path = "/{network_id}/example/{section}" page_patterns = ["/", "/news/*", "/reviews/*", "/deals/*"] formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }] [creative_opportunities.slot.providers.prebid] @@ -198,7 +198,7 @@ needed. `{section}` template with missing/invalid `section_root`. - [ ] `{section}` sanitized to `[A-Za-z0-9_-]`, derived from the raw path. - [ ] Documented in `docs/guide/configuration.md`, including unmatched-route - behavior and the no-decode rule; example and live autoblog configs updated. + behavior and the no-decode rule; example and live example configs updated. ## Sibling issue (not built here) From ca131e0c28a55d01304bd3cb993cc2a3fd53815e Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 11:56:34 -0500 Subject: [PATCH 102/494] Add privacy-safe auction-to-creative tracing Introduce query-activated diagnostic sessions, trace identities and telemetry, bounded GPT and Prebid render ownership, creative acknowledgements, and the browser timeline overlay. Add deterministic integration fixtures and coverage across edge adapters and browser render paths. --- .../setup-integration-test-env/action.yml | 29 + .github/workflows/integration-tests.yml | 23 +- crates/trusted-server-adapter-axum/src/app.rs | 3 +- .../src/middleware.rs | 46 + .../src/app.rs | 3 +- .../src/middleware.rs | 45 + .../trusted-server-adapter-fastly/src/app.rs | 3 +- .../trusted-server-adapter-fastly/src/main.rs | 2 + .../src/middleware.rs | 44 + .../src/tinybird.rs | 1 + crates/trusted-server-adapter-spin/src/app.rs | 9 +- .../src/middleware.rs | 38 +- .../benches/html_processor_bench.rs | 1 + .../src/auction/endpoints.rs | 42 +- .../src/auction/formats.rs | 204 +++- .../src/auction/orchestrator.rs | 493 ++++++--- .../src/auction/telemetry.rs | 222 ++-- .../src/auction/test_support.rs | 10 +- .../trusted-server-core/src/auction/types.rs | 147 +++ crates/trusted-server-core/src/config.rs | 11 +- crates/trusted-server-core/src/constants.rs | 4 + .../trusted-server-core/src/html_processor.rs | 28 +- .../src/integrations/ad_trace.rs | 620 +++++++++++ .../src/integrations/gpt_bootstrap.js | 54 + .../src/integrations/mod.rs | 5 + .../src/integrations/prebid.rs | 23 +- crates/trusted-server-core/src/openrtb.rs | 42 + crates/trusted-server-core/src/publisher.rs | 394 +++++-- .../src/response_privacy.rs | 12 +- .../browser/global-setup.ts | 13 +- .../browser/helpers/infra.ts | 1 + .../browser/helpers/state.ts | 2 +- .../browser/package.json | 1 + .../browser/playwright.config.ts | 8 +- .../tests/ad-trace/auction-trace.spec.ts | 439 ++++++++ .../tests/shared/ad-trace-gate.spec.ts | 20 + .../trusted-server.ad-trace.integration.toml | 67 ++ .../fixtures/frameworks/ad-trace/Dockerfile | 15 + .../frameworks/ad-trace/public/index.php | 201 ++++ .../frameworks/ad-trace/public/router.php | 50 + .../tests/parity.rs | 71 ++ .../lib/src/core/ad_trace.ts | 505 +++++++++ .../trusted-server-js/lib/src/core/auction.ts | 144 ++- .../lib/src/core/global.d.ts | 2 + .../trusted-server-js/lib/src/core/request.ts | 196 +++- .../trusted-server-js/lib/src/core/types.ts | 202 +++- .../lib/src/integrations/ad_trace/index.ts | 98 ++ .../lib/src/integrations/ad_trace/overlay.ts | 231 ++++ .../lib/src/integrations/gpt/index.ts | 983 +++++++++++++++++- .../lib/src/integrations/prebid/index.ts | 175 +++- .../lib/test/core/ad_trace.test.ts | 332 ++++++ .../lib/test/core/auction.test.ts | 153 ++- .../lib/test/core/request.test.ts | 142 ++- .../test/integrations/ad_trace/index.test.ts | 41 + .../integrations/ad_trace/overlay.test.ts | 110 ++ .../lib/test/integrations/gpt/ad_init.test.ts | 211 +++- .../test/integrations/gpt/ad_trace.test.ts | 327 ++++++ .../lib/test/integrations/gpt/index.test.ts | 31 +- .../test/integrations/prebid/index.test.ts | 83 ++ docs/guide/configuration.md | 17 + .../generate-integration-viceroy-configs.sh | 7 + scripts/integration-tests-browser.sh | 30 +- .../datasources/auction_events_raw.datasource | 1 + tinybird/fixtures/auction_events_raw.ndjson | 2 +- trusted-server.example.toml | 5 + 65 files changed, 6904 insertions(+), 570 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/ad_trace.rs create mode 100644 crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts create mode 100644 crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php create mode 100644 crates/trusted-server-js/lib/src/core/ad_trace.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts create mode 100644 crates/trusted-server-js/lib/test/core/ad_trace.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 841d8d5bd..12c41502a 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -93,6 +93,26 @@ runs: TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build -p trusted-server-adapter-axum + - name: Set up Node.js for browser fixtures + if: ${{ inputs.build-test-images == 'true' }} + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + + - name: Build external Prebid fixture bundle + if: ${{ inputs.build-test-images == 'true' }} + shell: bash + run: | + rm -rf "$GITHUB_WORKSPACE/target/integration-test-artifacts/prebid" + mkdir -p "$GITHUB_WORKSPACE/target/integration-test-artifacts/prebid" + npm ci --prefix crates/trusted-server-js/lib + npm run --prefix crates/trusted-server-js/lib build:prebid-external -- \ + --adapters=rubicon \ + --user-id-modules=sharedIdSystem \ + --out "$GITHUB_WORKSPACE/target/integration-test-artifacts/prebid" + - name: Build WordPress test container if: ${{ inputs.build-test-images == 'true' }} shell: bash @@ -109,6 +129,15 @@ runs: -t test-nextjs:latest \ crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ + - name: Build ad-trace test container + if: ${{ inputs.build-test-images == 'true' }} + shell: bash + run: | + docker build \ + -f crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile \ + -t test-ad-trace:latest \ + . + - name: Add wasm32-unknown-unknown target for Cloudflare build if: ${{ inputs.build-cloudflare == 'true' }} shell: bash diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ec85b96ac..da2c8c262 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -46,7 +46,7 @@ jobs: cp -r crates/trusted-server-adapter-cloudflare/build/. "$CF_BUILD_ARTIFACT_PATH/" docker save \ --output "$DOCKER_ARTIFACT_PATH" \ - test-wordpress:latest test-nextjs:latest + test-ad-trace:latest test-wordpress:latest test-nextjs:latest - name: Upload integration test artifacts uses: actions/upload-artifact@v4 @@ -228,10 +228,29 @@ jobs: path: crates/trusted-server-integration-tests/browser/playwright-report-wordpress/ retention-days: 7 + - name: Run browser tests (ad trace contract) + if: always() + working-directory: crates/trusted-server-integration-tests/browser + env: + WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-ad-trace.toml + TEST_FRAMEWORK: ad-trace + PLAYWRIGHT_HTML_REPORT: playwright-report-ad-trace + run: npx playwright test tests/ad-trace/auction-trace.spec.ts + + - name: Upload Playwright report (ad trace contract) + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-ad-trace + path: crates/trusted-server-integration-tests/browser/playwright-report-ad-trace/ + retention-days: 7 + - name: Upload Playwright traces and screenshots uses: actions/upload-artifact@v4 if: failure() with: name: playwright-traces - path: crates/trusted-server-integration-tests/browser/test-results/ + path: crates/trusted-server-integration-tests/browser/test-results-*/ retention-days: 7 diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..fe18e3604 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -32,7 +32,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AdTracePrepareMiddleware, AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{AxumPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- @@ -540,6 +540,7 @@ fn build_router(state: &Arc) -> RouterService { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AdTracePrepareMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); router = router.route("/health", Method::GET, |_ctx: RequestContext| async { diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 45cbedc2c..f3ea0d198 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -5,6 +5,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::settings::Settings; @@ -38,6 +39,51 @@ impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AdTracePrepareMiddleware +// --------------------------------------------------------------------------- + +/// Prepares and sanitizes the request before auth, routing, or downstream use. +pub struct AdTracePrepareMiddleware { + settings: Arc, +} + +impl AdTracePrepareMiddleware { + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AdTracePrepareMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); Ok(response) } } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f6..798e2a2e0 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -29,7 +29,7 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AdTracePrepareMiddleware, AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -432,6 +432,7 @@ fn build_router(state: &Arc) -> RouterService { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AdTracePrepareMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( "/.well-known/trusted-server.json", diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 5b605bcff..de15ac62b 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -5,6 +5,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::settings::Settings; @@ -46,6 +47,50 @@ impl Middleware for FinalizeResponseMiddleware { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AdTracePrepareMiddleware +// --------------------------------------------------------------------------- + +pub struct AdTracePrepareMiddleware { + settings: Arc, +} + +impl AdTracePrepareMiddleware { + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AdTracePrepareMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); Ok(response) } } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235b..841d1b731 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -129,7 +129,7 @@ use trusted_server_core::settings_data::{ }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AdTracePrepareMiddleware, AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, FastlyPlatformSecretStore, UnavailableKvStore, open_kv_store, @@ -1161,6 +1161,7 @@ impl TrustedServerApp { Arc::clone(&state.settings), Arc::new(FastlyPlatformGeo), )) + .middleware(AdTracePrepareMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); let fallback_handler = fallback_route_handler(Arc::clone(state)); diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index ab3236c0f..8eea9f444 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -337,6 +337,8 @@ fn send_edgezero_response( // added a per-user Set-Cookie after `apply_finalize_headers` ran, so // re-apply the privacy downgrade before send. crate::middleware::enforce_set_cookie_cache_privacy(&mut response); + // Reassert console no-store after asset/EC/filter response mutations. + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); let (parts, body) = response.into_parts(); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 2c00ac2ff..f6a674301 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -85,6 +85,7 @@ impl Middleware for FinalizeResponseMiddleware { }); apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); response .headers_mut() .insert(HEADER_X_TS_FINALIZED, HeaderValue::from_static("1")); @@ -93,6 +94,49 @@ impl Middleware for FinalizeResponseMiddleware { } } +// --------------------------------------------------------------------------- +// AdTracePrepareMiddleware +// --------------------------------------------------------------------------- + +/// Sanitizes and snapshots the console decision before auth and route dispatch. +pub struct AdTracePrepareMiddleware { + settings: Arc, +} + +impl AdTracePrepareMiddleware { + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AdTracePrepareMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); + Ok(response) + } +} + // --------------------------------------------------------------------------- // AuthMiddleware // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 217c167b8..6c5aeea15 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -418,6 +418,7 @@ mod tests { price_cpm: None, currency: None, is_win: None, + bid_trace_id: None, ad_domain: None, ad_id: None, } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce74..dbfb5c4fd 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -705,13 +705,10 @@ fn build_router(state: &Arc) -> RouterService { let mut builder = RouterService::builder() .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + // Normalize and sanitize outside auth so even auth short-circuits + // cannot forward reserved console inputs or skip response actions. + .middleware(NormalizeMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) - // Innermost middleware: normalize every routed request (strip - // spoofable forwarded headers, derive the trusted Host/scheme/client-IP - // from Spin's synthetic runtime headers) so no handler can opt out of - // the de-spoofing invariant. Runs after auth so the basic-auth gate - // continues to see the original request, matching prior behaviour. - .middleware(NormalizeMiddleware::new()) // Cheap liveness probe, matching the Fastly/Axum adapters. Registered // explicitly so it is not absorbed by the publisher `/{*rest}` fallback. .get("/health", |_ctx: RequestContext| async { diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 1bcede1fc..1f9178057 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -5,6 +5,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::settings::Settings; @@ -39,6 +40,7 @@ impl Middleware for FinalizeResponseMiddleware { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); Ok(response) } } @@ -95,16 +97,17 @@ impl Middleware for AuthMiddleware { /// signing handler that begins deriving an issuer/audience from `RequestInfo`, /// cannot silently trust spoofable input by forgetting to opt in. /// -/// Registered after [`AuthMiddleware`] (innermost) so the basic-auth gate still -/// evaluates the original request, preserving prior behaviour. -#[derive(Default)] -pub struct NormalizeMiddleware; +/// Registered outside [`AuthMiddleware`] so de-spoofing and console sanitation +/// also apply when auth short-circuits the request. +pub struct NormalizeMiddleware { + settings: Arc, +} impl NormalizeMiddleware { /// Creates a new [`NormalizeMiddleware`]. #[must_use] - pub fn new() -> Self { - Self + pub fn new(settings: Arc) -> Self { + Self { settings } } } @@ -112,7 +115,28 @@ impl NormalizeMiddleware { impl Middleware for NormalizeMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { crate::app::normalize_spin_request(ctx.request_mut()); - next.run(ctx).await + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); + Ok(response) } } diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 6c7a397b0..24b034ec9 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -9,6 +9,7 @@ fn make_config() -> HtmlProcessorConfig { request_host: "proxy.bench.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 1b0ced7a7..eb306dc5d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,7 +1,5 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; - use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{Request, Response, StatusCode, header}; @@ -24,12 +22,12 @@ use crate::platform::RuntimeServices; use crate::settings::Settings; use super::AuctionOrchestrator; -use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request}; +use super::formats::{convert_to_openrtb_response_with_trace, convert_tsjs_to_auction_request}; use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::AuctionContext; +use super::types::{AuctionContext, AuctionPublicOutcome, AuctionTraceContext}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -159,6 +157,8 @@ pub async fn handle_auction( ); let http_req = Request::from_parts(parts, EdgeBody::empty()); + let trace_enabled = crate::integrations::ad_trace::browser_trace_enabled(&http_req); + let trace = AuctionTraceContext::new(AuctionSource::AuctionApi); // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. @@ -192,11 +192,8 @@ pub async fn handle_auction( ec_id, None, )?; - let observation = AuctionObservationContext::from_auction_request( - AuctionSource::AuctionApi, - &auction_request, - ec_context, - ); + let observation = + AuctionObservationContext::from_auction_request(&trace, &auction_request, ec_context); emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -208,18 +205,13 @@ pub async fn handle_auction( }) .await; - let empty_result = OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 0, - metadata: HashMap::new(), - }; - return convert_to_openrtb_response( + let empty_result = OrchestrationResult::empty(trace, AuctionPublicOutcome::Skipped); + return convert_to_openrtb_response_with_trace( &empty_result, settings, &auction_request, ec_context.ec_allowed(), + trace_enabled, ); } @@ -281,6 +273,7 @@ pub async fn handle_auction( // Create auction context let context = AuctionContext { + trace: &trace, settings, request: &http_req, timeout_ms: settings.auction.timeout_ms, @@ -288,11 +281,8 @@ pub async fn handle_auction( services, }; - let observation = AuctionObservationContext::from_auction_request( - AuctionSource::AuctionApi, - &auction_request, - ec_context, - ); + let observation = + AuctionObservationContext::from_auction_request(&trace, &auction_request, ec_context); // Run the auction let result = match orchestrator.run_auction(&auction_request, &context).await { @@ -336,7 +326,13 @@ pub async fn handle_auction( ); // Convert to OpenRTB response format with inline creative HTML - convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed()) + convert_to_openrtb_response_with_trace( + &result, + settings, + &auction_request, + ec_context.ec_allowed(), + trace_enabled, + ) } /// Resolves partner EIDs from the KV identity graph for bidstream decoration. diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a18..0ed059d00 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -19,7 +19,10 @@ use crate::creative; use crate::ec::eids::encode_eids_header; use crate::error::TrustedServerError; use crate::geo::GeoInfo; -use crate::openrtb::{OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt, to_openrtb_i32}; +use crate::openrtb::{ + AuctionTraceWire, BidTraceWire, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt, + TrustedServerBidExt, TrustedServerBidTraceContainer, TrustedServerResponseExt, to_openrtb_i32, +}; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -229,6 +232,21 @@ pub fn convert_to_openrtb_response( settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, +) -> Result, Report> { + convert_to_openrtb_response_with_trace(result, settings, auction_request, ec_allowed, false) +} + +/// Convert an auction result with optional tester-gated trace extensions. +/// +/// # Errors +/// +/// Returns the same errors as [`convert_to_openrtb_response`]. +pub fn convert_to_openrtb_response_with_trace( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, + trace_enabled: bool, ) -> Result, Report> { // Build OpenRTB-style seatbid array let mut seatbids = Vec::with_capacity(result.winning_bids.len()); @@ -275,6 +293,24 @@ pub fn convert_to_openrtb_response( String::new() }; + let bid_ext = trace_enabled + .then(|| result.trace.winning_bids.get(slot_id)) + .flatten() + .and_then(|trace| { + TrustedServerBidExt { + trusted_server: TrustedServerBidTraceContainer { + trace: BidTraceWire { + version: 1, + bid_trace_id: trace.bid_trace_id.to_string(), + slot_id: slot_id.clone(), + provider: trace.provider.clone(), + bidder: trace.bidder.clone(), + }, + }, + } + .to_ext() + }); + let openrtb_bid = OpenRtbBid { id: Some(format!("{}-{}", bid.bidder, slot_id)), impid: Some(slot_id.to_string()), @@ -284,6 +320,7 @@ pub fn convert_to_openrtb_response( w: width, h: height, adomain: bid.adomain.clone().unwrap_or_default(), + ext: bid_ext, ..Default::default() }; @@ -319,6 +356,14 @@ pub fn convert_to_openrtb_response( time_ms: result.total_time_ms, provider_details, }, + trusted_server: trace_enabled.then(|| TrustedServerResponseExt { + trace: AuctionTraceWire { + version: 1, + auction_trace_id: result.trace.summary.auction.auction_trace_id.to_string(), + source: result.trace.summary.auction.source.as_str(), + outcome: result.trace.summary.outcome.as_str(), + }, + }), } .to_ext(), ..Default::default() @@ -416,13 +461,14 @@ mod tests { } fn make_empty_result() -> OrchestrationResult { - OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 10, - metadata: HashMap::new(), - } + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new( + crate::auction::types::AuctionSource::AuctionApi, + ), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.total_time_ms = 10; + result } fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid { @@ -446,19 +492,34 @@ mod tests { } fn make_result(bid: Bid) -> OrchestrationResult { - OrchestrationResult { - provider_responses: vec![AuctionResponse { - provider: "prebid".to_string(), - bids: vec![bid.clone()], - status: BidStatus::Success, - response_time_ms: 42, - metadata: HashMap::new(), - }], - mediator_response: None, - winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), - total_time_ms: 50, + let mut result = make_empty_result(); + result.trace.summary.outcome = crate::auction::types::AuctionPublicOutcome::Completed; + result.trace.winning_bids.insert( + bid.slot_id.clone(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: bid.bidder.clone(), + }, + ); + result.winning_bid_origins.insert( + bid.slot_id.clone(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + ); + result.provider_responses = vec![AuctionResponse { + provider: "prebid".to_string(), + bids: vec![bid.clone()], + status: BidStatus::Success, + response_time_ms: 42, metadata: HashMap::new(), - } + }]; + result.winning_bids = HashMap::from([(bid.slot_id.clone(), bid)]); + result.total_time_ms = 50; + result } fn response_json(response: Response) -> JsonValue { @@ -974,17 +1035,69 @@ mod tests { ); } + #[test] + fn gated_response_adds_namespaced_root_and_winning_bid_trace() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response_with_trace( + &result, + &settings, + &auction_request, + false, + true, + ) + .expect("should convert traced response"); + let json = response_json(response); + + assert_eq!( + json["ext"]["trusted_server"]["trace"]["auction_trace_id"], + json!(result.trace.summary.auction.auction_trace_id.to_string()), + "should expose the shared trace identity" + ); + assert_eq!( + json["seatbid"][0]["bid"][0]["ext"]["trusted_server"]["trace"]["bid_trace_id"], + json!( + result.trace.winning_bids["div-gpt-top"] + .bid_trace_id + .to_string() + ), + "should expose only the final winner trace" + ); + assert_ne!( + json["ext"]["trusted_server"]["trace"]["auction_trace_id"], + json!(auction_request.id), + "should never expose the internal request ID as trace identity" + ); + } + + #[test] + fn ungated_response_omits_all_trace_extensions() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert legacy response"); + let json = response_json(response); + + assert!( + json["ext"].get("trusted_server").is_none(), + "ungated root should omit trace" + ); + assert!( + json["seatbid"][0]["bid"][0].get("ext").is_none(), + "ungated bid should omit trace" + ); + } + #[test] fn convert_to_openrtb_response_allows_empty_winning_bids() { let settings = make_settings(); let auction_request = make_auction_request(); - let result = OrchestrationResult { - provider_responses: vec![], - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 50, - metadata: HashMap::new(), - }; + let mut result = make_empty_result(); + result.total_time_ms = 50; let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) .expect("should convert auction result without winning bids"); @@ -1009,22 +1122,27 @@ mod tests { let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25)); sidebar_bid.creative = Some("
Sidebar
".to_string()); - let result = OrchestrationResult { - provider_responses: vec![AuctionResponse { - provider: "prebid".to_string(), - bids: vec![top_bid.clone(), sidebar_bid.clone()], - status: BidStatus::Success, - response_time_ms: 42, - metadata: HashMap::new(), - }], - mediator_response: None, - winning_bids: HashMap::from([ - (top_bid.slot_id.clone(), top_bid), - (sidebar_bid.slot_id.clone(), sidebar_bid), - ]), - total_time_ms: 50, - metadata: HashMap::new(), - }; + let mut result = make_result(top_bid.clone()); + result.provider_responses[0].bids.push(sidebar_bid.clone()); + result.trace.winning_bids.insert( + sidebar_bid.slot_id.clone(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: sidebar_bid.bidder.clone(), + }, + ); + result.winning_bid_origins.insert( + sidebar_bid.slot_id.clone(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 1, + mediated: false, + }, + ); + result + .winning_bids + .insert(sidebar_bid.slot_id.clone(), sidebar_bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) .expect("should convert multiple winning bids"); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bee63856f..70b829dcf 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -12,7 +12,11 @@ use crate::platform::{PlatformPendingRequest, RuntimeServices}; use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::telemetry::AbandonedProviderCall; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +use super::types::{ + AuctionContext, AuctionPublicOutcome, AuctionRequest, AuctionResponse, AuctionResultTrace, + AuctionTraceContext, AuctionTraceSummary, Bid, BidStatus, BidTraceId, WinningBidOrigin, + WinningBidTrace, +}; /// In-flight auction requests dispatched to SSP backends. /// @@ -22,6 +26,7 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStat /// race in Fastly's native layer, enabling TTFB ≈ origin latency rather than /// TTFB ≈ auction timeout. pub struct DispatchedAuction { + trace: AuctionTraceContext, pending_requests: Vec, backend_to_provider: HashMap)>, launch_responses: Vec, @@ -50,6 +55,12 @@ pub enum DispatchAuctionOutcome { } impl DispatchedAuction { + /// Return the trace context retained for split-phase collection. + #[must_use] + pub fn trace(&self) -> &AuctionTraceContext { + &self.trace + } + /// Consume the dispatch token without collecting provider responses. #[must_use] pub fn abandon( @@ -79,6 +90,7 @@ impl DispatchedAuction { impl DispatchedAuction { pub(crate) fn empty_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { Self { + trace: AuctionTraceContext::new(super::types::AuctionSource::InitialNavigation), pending_requests: Vec::new(), backend_to_provider: HashMap::new(), launch_responses: Vec::new(), @@ -146,6 +158,39 @@ fn provider_transport_failed_response( .with_metadata("message", serde_json::json!("Provider request failed")) } +fn build_winning_bid_traces( + winning_bids: &HashMap, + origins: &HashMap, + provider_responses: &[AuctionResponse], + mediator_response: Option<&AuctionResponse>, + mut id_source: impl FnMut() -> BidTraceId, +) -> HashMap { + let mut traces = HashMap::with_capacity(winning_bids.len()); + for (slot_id, bid) in winning_bids { + let provider = origins + .get(slot_id) + .and_then(|origin| { + if origin.mediated { + mediator_response.map(|response| response.provider.clone()) + } else { + provider_responses + .get(origin.response_index) + .map(|response| response.provider.clone()) + } + }) + .unwrap_or_else(|| "unattributed".to_owned()); + traces.insert( + slot_id.clone(), + WinningBidTrace { + bid_trace_id: id_source(), + provider, + bidder: bid.bidder.clone(), + }, + ); + } + traces +} + fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> AuctionResponse { AuctionResponse::error(provider_name, response_time_ms) .with_metadata("error_type", serde_json::json!(ERROR_TYPE_TIMEOUT)) @@ -273,115 +318,100 @@ impl AuctionOrchestrator { let provider_responses = self.run_providers_parallel(request, context).await?; let floor_prices = self.floor_prices_by_slot(request); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - let mediator = self.get_provider(mediator_name)?; - - log::info!( - "Sending {} provider responses to mediator: {}", - provider_responses.len(), - mediator.provider_name() - ); - - // Give the mediator only the remaining time from the auction - // deadline, not the full timeout — the bidding phase already - // consumed part of it. - let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - - if remaining_ms == 0 { - log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - return Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: 0, - metadata: HashMap::new(), - }); - } - - let mediator_context = AuctionContext { - settings: context.settings, - request: context.request, - // Bound by both the remaining auction budget and the mediator's - // own configured timeout, matching the dispatched collect path. - timeout_ms: remaining_ms.min(mediator.timeout_ms()), - provider_responses: Some(&provider_responses), - services: context.services, - }; - - let start_time = Instant::now(); - let pending = mediator - .request_bids(request, &mediator_context) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} failed to launch", mediator.provider_name()), - })?; + let (mediator_response, winning_bids, winning_bid_origins) = + if let Some(mediator_name) = &self.config.mediator { + let mediator = self.get_provider(mediator_name)?; - let platform_resp = mediator_context - .services - .http_client() - .wait(pending) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} request failed", mediator.provider_name()), - })?; + log::info!( + "Sending {} provider responses to mediator: {}", + provider_responses.len(), + mediator.provider_name() + ); - let response_time_ms = start_time.elapsed().as_millis() as u64; - // Use the context-aware parse so mediators (e.g. adserver_mock) can - // restore nurl/burl/ad_id and PBS cache fields from the collected SSP - // responses. The dispatched collect path already does this; the - // synchronous mediation path used by POST /auction and - // /__ts/page-bids must match or mediated cache bids lose the metadata - // needed for creative rendering and win/billing beacons. - let mediator_resp = mediator - .parse_response_with_context( - platform_resp, - response_time_ms, - request, - &mediator_context, - ) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} parse failed", mediator.provider_name()), - })?; + // Give the mediator only the remaining time from the auction + // deadline, not the full timeout — the bidding phase already + // consumed part of it. + let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + + if remaining_ms == 0 { + log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&provider_responses, &floor_prices); + return Ok(self.finalize_result( + context.trace, + provider_responses, + None, + winning_bids, + winning_bid_origins, + 0, + )); + } - // Extract winning bids from mediator response - // Filter out bids without decoded prices - mediator should have decoded all prices - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping. \ - Mediator should decode all prices including APS bids.", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); + let mediator_context = AuctionContext { + trace: context.trace, + settings: context.settings, + request: context.request, + // Bound by both the remaining auction budget and the mediator's + // own configured timeout, matching the dispatched collect path. + timeout_ms: remaining_ms.min(mediator.timeout_ms()), + provider_responses: Some(&provider_responses), + services: context.services, + }; - ( - Some(mediator_resp), - self.apply_floor_prices(winning, &floor_prices), - ) - } else { - // No mediator - select best bid per slot from bidder responses - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - (None, winning) - }; + let start_time = Instant::now(); + let pending = mediator + .request_bids(request, &mediator_context) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} failed to launch", mediator.provider_name()), + })?; + + let platform_resp = mediator_context + .services + .http_client() + .wait(pending) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} request failed", mediator.provider_name()), + })?; + + let response_time_ms = start_time.elapsed().as_millis() as u64; + // Use the context-aware parse so mediators (e.g. adserver_mock) can + // restore nurl/burl/ad_id and PBS cache fields from the collected SSP + // responses. The dispatched collect path already does this; the + // synchronous mediation path used by POST /auction and + // /__ts/page-bids must match or mediated cache bids lose the metadata + // needed for creative rendering and win/billing beacons. + let mediator_resp = mediator + .parse_response_with_context( + platform_resp, + response_time_ms, + request, + &mediator_context, + ) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} parse failed", mediator.provider_name()), + })?; + + let (winning_bids, winning_bid_origins) = + self.select_mediator_winning_bids(&mediator_resp, &floor_prices); + (Some(mediator_resp), winning_bids, winning_bid_origins) + } else { + // No mediator - select best bid per slot from bidder responses + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&provider_responses, &floor_prices); + (None, winning_bids, winning_bid_origins) + }; - Ok(OrchestrationResult { + Ok(self.finalize_result( + context.trace, provider_responses, mediator_response, winning_bids, - total_time_ms: 0, // Will be set by caller - metadata: HashMap::new(), - }) + winning_bid_origins, + 0, + )) } /// Run auction with only parallel bidding (no mediation). @@ -392,15 +422,17 @@ impl AuctionOrchestrator { ) -> Result> { let provider_responses = self.run_providers_parallel(request, context).await?; let floor_prices = self.floor_prices_by_slot(request); - let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices); + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&provider_responses, &floor_prices); - Ok(OrchestrationResult { + Ok(self.finalize_result( + context.trace, provider_responses, - mediator_response: None, + None, winning_bids, - total_time_ms: 0, - metadata: HashMap::new(), - }) + winning_bid_origins, + 0, + )) } /// Run all providers in parallel and collect responses. @@ -495,6 +527,7 @@ impl AuctionOrchestrator { }; let provider_context = AuctionContext { + trace: context.trace, settings: context.settings, request: context.request, timeout_ms: effective_timeout, @@ -698,22 +731,23 @@ impl AuctionOrchestrator { Ok(responses) } - /// Select the best bid for each slot from all responses. + /// Select the best bid for each slot from all responses while retaining its exact origin. /// Note: Bids with None price (e.g., APS bids with encoded prices) are skipped /// when no mediator is configured, as we cannot compare them without decoding. fn select_winning_bids( &self, responses: &[AuctionResponse], floor_prices: &HashMap, - ) -> HashMap { + ) -> (HashMap, HashMap) { let mut winning_bids: HashMap = HashMap::new(); + let mut origins = HashMap::new(); - for response in responses { + for (response_index, response) in responses.iter().enumerate() { if response.status != BidStatus::Success { continue; } - for bid in &response.bids { + for (bid_index, bid) in response.bids.iter().enumerate() { // Skip bids without decoded prices (e.g., APS bids) // These require mediation layer to decode let bid_price = match bid.price { @@ -736,12 +770,91 @@ impl AuctionOrchestrator { }; if should_replace { + origins.insert( + bid.slot_id.clone(), + WinningBidOrigin { + response_index, + bid_index, + mediated: false, + }, + ); winning_bids.insert(bid.slot_id.clone(), bid.clone()); } } } - self.apply_floor_prices(winning_bids, floor_prices) + let winning_bids = self.apply_floor_prices(winning_bids, floor_prices); + origins.retain(|slot_id, _| winning_bids.contains_key(slot_id)); + (winning_bids, origins) + } + + fn select_mediator_winning_bids( + &self, + response: &AuctionResponse, + floor_prices: &HashMap, + ) -> (HashMap, HashMap) { + let mut winning_bids = HashMap::new(); + let mut origins = HashMap::new(); + for (bid_index, bid) in response.bids.iter().enumerate() { + if bid.price.is_none() { + log::warn!( + "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", + response.provider, + bid.slot_id + ); + continue; + } + origins.insert( + bid.slot_id.clone(), + WinningBidOrigin { + response_index: 0, + bid_index, + mediated: true, + }, + ); + winning_bids.insert(bid.slot_id.clone(), bid.clone()); + } + let winning_bids = self.apply_floor_prices(winning_bids, floor_prices); + origins.retain(|slot_id, _| winning_bids.contains_key(slot_id)); + (winning_bids, origins) + } + + fn finalize_result( + &self, + trace: &AuctionTraceContext, + provider_responses: Vec, + mediator_response: Option, + winning_bids: HashMap, + winning_bid_origins: HashMap, + total_time_ms: u64, + ) -> OrchestrationResult { + let outcome = if winning_bids.is_empty() { + AuctionPublicOutcome::NoBid + } else { + AuctionPublicOutcome::Completed + }; + let trace_bids = build_winning_bid_traces( + &winning_bids, + &winning_bid_origins, + &provider_responses, + mediator_response.as_ref(), + BidTraceId::new, + ); + OrchestrationResult { + trace: AuctionResultTrace { + summary: AuctionTraceSummary { + auction: trace.clone(), + outcome, + }, + winning_bids: trace_bids, + }, + winning_bid_origins, + provider_responses, + mediator_response, + winning_bids, + total_time_ms, + metadata: HashMap::new(), + } } fn apply_floor_prices( @@ -905,6 +1018,7 @@ impl AuctionOrchestrator { }; let provider_context = AuctionContext { + trace: context.trace, settings: context.settings, request: context.request, timeout_ms: effective_timeout, @@ -965,6 +1079,7 @@ impl AuctionOrchestrator { ); DispatchAuctionOutcome::Dispatched(DispatchedAuction { + trace: context.trace.clone(), pending_requests, backend_to_provider, launch_responses, @@ -992,6 +1107,7 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> OrchestrationResult { let DispatchedAuction { + trace, pending_requests, mut backend_to_provider, launch_responses, @@ -1128,7 +1244,7 @@ impl AuctionOrchestrator { } backend_to_provider.clear(); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { + let (mediator_response, selection) = if let Some(mediator_name) = &self.config.mediator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { // Cap the mediator at whichever is tighter: its own configured @@ -1146,14 +1262,16 @@ impl AuctionOrchestrator { mediator.provider_name(), responses.len(), ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - }; + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&responses, &floor_prices); + return self.finalize_result( + &trace, + responses, + None, + winning_bids, + winning_bid_origins, + auction_start.elapsed().as_millis() as u64, + ); } let mediator_timeout = remaining.min(mediator.timeout_ms()); let mediator_start = Instant::now(); @@ -1175,6 +1293,7 @@ impl AuctionOrchestrator { .body(edgezero_core::body::Body::empty()) .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); let mediator_context = AuctionContext { + trace: &trace, settings: context.settings, request: &placeholder, timeout_ms: mediator_timeout, @@ -1206,25 +1325,11 @@ impl AuctionOrchestrator { .await { Ok(mediator_resp) => { - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - let winning = - self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_resp), winning) + let selection = self.select_mediator_winning_bids( + &mediator_resp, + &floor_prices, + ); + (Some(mediator_resp), selection) } Err(e) => { log::warn!( @@ -1265,13 +1370,15 @@ impl AuctionOrchestrator { (None, self.select_winning_bids(&responses, &floor_prices)) }; - OrchestrationResult { - provider_responses: responses, + let (winning_bids, winning_bid_origins) = selection; + self.finalize_result( + &trace, + responses, mediator_response, winning_bids, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - } + winning_bid_origins, + auction_start.elapsed().as_millis() as u64, + ) } /// Check if orchestrator is enabled. @@ -1284,6 +1391,10 @@ impl AuctionOrchestrator { /// Result of an orchestrated auction. #[derive(Debug, Clone)] pub struct OrchestrationResult { + /// Privacy-safe tester trace for this finalized result. + pub trace: AuctionResultTrace, + /// Exact internal origin of each final winning bid. + pub(crate) winning_bid_origins: HashMap, /// All responses from providers pub provider_responses: Vec, /// Final response from mediator (if used) @@ -1297,6 +1408,32 @@ pub struct OrchestrationResult { } impl OrchestrationResult { + /// Build a no-bid result for a terminal path that already returns a response. + #[must_use] + pub fn empty(trace: AuctionTraceContext, outcome: AuctionPublicOutcome) -> Self { + Self { + trace: AuctionResultTrace { + summary: AuctionTraceSummary { + auction: trace, + outcome, + }, + winning_bids: HashMap::new(), + }, + winning_bid_origins: HashMap::new(), + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + + /// Return the exact provider/bid location for a final winning slot. + #[must_use] + pub(crate) fn winning_origin(&self, slot_id: &str) -> Option { + self.winning_bid_origins.get(slot_id).copied() + } + /// Get the winning bid for a specific slot. #[must_use] pub fn get_winning_bid(&self, slot_id: &str) -> Option<&Bid> { @@ -1331,7 +1468,7 @@ mod tests { use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, - MediaType, PublisherInfo, UserInfo, + BidTraceId, MediaType, PublisherInfo, UserInfo, WinningBidOrigin, }; use crate::error::TrustedServerError; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; @@ -1343,7 +1480,7 @@ mod tests { use std::collections::{HashMap, HashSet}; use std::sync::Arc; - use super::AuctionOrchestrator; + use super::{AuctionOrchestrator, build_winning_bid_traces}; // --------------------------------------------------------------------------- // Minimal test double for AuctionProvider @@ -1429,6 +1566,62 @@ mod tests { } } + #[test] + fn mediated_selection_retains_the_mediator_response_origin() { + let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); + let selected = mediated_bid(None); + let mediator_response = AuctionResponse::success("mediator", vec![selected], 1); + + let (_, origins) = + orchestrator.select_mediator_winning_bids(&mediator_response, &HashMap::new()); + + let origin = origins["header-banner"]; + assert!( + origin.mediated, + "mediated selection should retain mediator origin" + ); + assert_eq!( + origin.bid_index, 0, + "should retain exact mediator bid index" + ); + } + + #[test] + fn winning_trace_builder_uses_supplied_id_source_only_for_final_winners() { + let mut winner = mediated_bid(None); + winner.bidder = "example-bidder".to_owned(); + let provider_responses = vec![AuctionResponse::success( + "provider-a", + vec![winner.clone()], + 1, + )]; + let winning_bids = HashMap::from([("header-banner".to_owned(), winner)]); + let origins = HashMap::from([( + "header-banner".to_owned(), + WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + )]); + let fixed = uuid::Uuid::parse_str("650e8400-e29b-41d4-a716-446655440000") + .expect("should parse fixed UUID"); + let mut calls = 0; + + let traces = + build_winning_bid_traces(&winning_bids, &origins, &provider_responses, None, || { + calls += 1; + BidTraceId::from_uuid(fixed) + }); + + assert_eq!(calls, 1, "should allocate one ID for one final winner"); + assert_eq!( + traces["header-banner"].bid_trace_id.to_string(), + fixed.to_string(), + "should use the supplied deterministic ID" + ); + } + #[async_trait::async_trait(?Send)] impl AuctionProvider for CacheRestoringMediator { fn provider_name(&self) -> &'static str { @@ -1530,6 +1723,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -1974,6 +2168,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2057,6 +2252,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2122,6 +2318,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index d63445369..08af9ac0f 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -3,7 +3,6 @@ //! Core owns the privacy-preserving auction observation model and pure row //! builder. Platform adapters provide the concrete sink implementation. -use std::collections::HashSet; use std::time::Instant; use chrono::Utc; @@ -12,7 +11,10 @@ use serde::Serialize; use uuid::Uuid; use crate::auction::orchestrator::OrchestrationResult; -use crate::auction::types::{AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType}; +pub use crate::auction::types::AuctionSource; +use crate::auction::types::{ + AuctionRequest, AuctionResponse, AuctionTraceContext, Bid, BidStatus, MediaType, +}; use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::platform::RuntimeServices; @@ -20,27 +22,6 @@ use crate::platform::RuntimeServices; const MAX_PAGE_PATH_BYTES: usize = 256; const DYNAMIC_SEGMENT_REPLACEMENT: &str = ":id"; -/// Source path that initiated an auction candidate. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum AuctionSource { - /// Initial publisher navigation using server-side ad templates. - InitialNavigation, - /// SPA navigation through `GET /__ts/page-bids`. - SpaNavigation, - /// Explicit `POST /auction` API. - AuctionApi, -} - -impl AuctionSource { - fn as_str(self) -> &'static str { - match self { - Self::InitialNavigation => "initial_navigation", - Self::SpaNavigation => "spa_navigation", - Self::AuctionApi => "auction_api", - } - } -} - /// Terminal status for one auction observation. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum AuctionTerminalStatus { @@ -123,7 +104,7 @@ impl AuctionObservationContext { /// Build an observation context from an auction request. #[must_use] pub fn from_auction_request( - auction_source: AuctionSource, + trace: &AuctionTraceContext, request: &AuctionRequest, ec_context: &EcContext, ) -> Self { @@ -135,7 +116,7 @@ impl AuctionObservationContext { .map(|url| url.path().to_owned()) .unwrap_or_else(|| "/".to_owned()); Self::from_parts( - auction_source, + trace, &request.publisher.domain, &raw_path, request.slots.len(), @@ -146,7 +127,7 @@ impl AuctionObservationContext { /// Build an observation context from publisher request parts. #[must_use] pub fn from_parts( - auction_source: AuctionSource, + trace: &AuctionTraceContext, publisher_domain: &str, raw_page_path: &str, slot_count: usize, @@ -157,8 +138,8 @@ impl AuctionObservationContext { let consent = ec_context.consent(); let slot_count = u16::try_from(slot_count).unwrap_or(u16::MAX); Self { - auction_id: Uuid::new_v4(), - auction_source, + auction_id: trace.auction_trace_id.as_uuid(), + auction_source: trace.source, publisher_domain: publisher_domain.to_owned(), page_path: normalize_page_path(raw_page_path), country: geo @@ -264,7 +245,7 @@ pub struct AuctionEventRow { pub event_ts: String, /// `summary`, `provider_call`, or `bid`. pub event_kind: String, - /// Fresh telemetry auction UUID. + /// Privacy-safe UUID shared with tester-gated trace output. pub auction_id: String, /// Source path label. pub auction_source: String, @@ -320,6 +301,8 @@ pub struct AuctionEventRow { pub currency: Option, /// Whether this is the canonical winning row for its slot. pub is_win: Option, + /// Trace UUID for the canonical winning bid only. + pub bid_trace_id: Option, /// Advertiser domain. pub ad_domain: Option, /// Creative/ad ID. @@ -359,6 +342,7 @@ impl AuctionEventRow { price_cpm: None, currency: None, is_win: None, + bid_trace_id: None, ad_domain: None, ad_id: None, } @@ -683,68 +667,84 @@ fn push_bid_rows( request: &AuctionRequest, result: &OrchestrationResult, ) { - let mut matched_wins = HashSet::new(); - - for response in &result.provider_responses { - for bid in &response.bids { - let matched_slot = result - .winning_bids - .iter() - .find(|(slot_id, winning)| { - !matched_wins.contains(*slot_id) && bid_matches_winning_bid(bid, winning) + for (response_index, response) in result.provider_responses.iter().enumerate() { + for (bid_index, bid) in response.bids.iter().enumerate() { + let winning_slot = result.winning_bids.keys().find(|slot_id| { + result.winning_origin(slot_id).is_some_and(|origin| { + !origin.mediated + && origin.response_index == response_index + && origin.bid_index == bid_index }) - .map(|(slot_id, winning)| (slot_id.clone(), winning)); - let (is_win, price) = if let Some((slot_id, winning)) = matched_slot { - matched_wins.insert(slot_id); - (1, bid.price.or(winning.price)) - } else { - (0, bid.price) - }; + }); + let trace_id = winning_slot.and_then(|slot_id| { + result + .trace + .winning_bids + .get(slot_id) + .map(|trace| trace.bid_trace_id.to_string()) + }); + let price = winning_slot + .and_then(|slot_id| result.winning_bids.get(slot_id)) + .and_then(|winning| winning.price) + .or(bid.price); rows.push(bid_row( observation, event_ts, request, &response.provider, bid, - is_win, - price, + BidRowOutcome { + is_win: u8::from(winning_slot.is_some()), + price, + bid_trace_id: trace_id, + }, )); } } if let Some(mediator_response) = &result.mediator_response { - for (slot_id, winning) in &result.winning_bids { - if matched_wins.contains(slot_id) { - continue; - } - if mediator_response - .bids - .iter() - .any(|bid| bid_matches_winning_bid(bid, winning)) - { + for (bid_index, bid) in mediator_response.bids.iter().enumerate() { + let winning_slot = result.winning_bids.keys().find(|slot_id| { + result + .winning_origin(slot_id) + .is_some_and(|origin| origin.mediated && origin.bid_index == bid_index) + }); + if let Some(slot_id) = winning_slot { + let trace_id = result + .trace + .winning_bids + .get(slot_id) + .map(|trace| trace.bid_trace_id.to_string()); rows.push(bid_row( observation, event_ts, request, &mediator_response.provider, - winning, - 1, - winning.price, + bid, + BidRowOutcome { + is_win: 1, + price: bid.price, + bid_trace_id: trace_id, + }, )); - matched_wins.insert(slot_id.clone()); } } } } +struct BidRowOutcome { + is_win: u8, + price: Option, + bid_trace_id: Option, +} + fn bid_row( observation: &AuctionObservationContext, event_ts: &str, request: &AuctionRequest, provider: &str, bid: &Bid, - is_win: u8, - price: Option, + outcome: BidRowOutcome, ) -> AuctionEventRow { let mut row = AuctionEventRow::base(observation, "bid", event_ts); row.provider = Some(provider.to_owned()); @@ -753,9 +753,10 @@ fn bid_row( row.slot_h = Some(u16::try_from(bid.height).unwrap_or(u16::MAX)); row.media_type = media_type_for_slot(request, &bid.slot_id).map(str::to_owned); row.seat = Some(bid.bidder.clone()); - row.price_cpm = price; + row.price_cpm = outcome.price; row.currency = Some(bid.currency.clone()); - row.is_win = Some(is_win); + row.is_win = Some(outcome.is_win); + row.bid_trace_id = outcome.bid_trace_id; row.ad_domain = bid .adomain .as_ref() @@ -764,16 +765,6 @@ fn bid_row( row } -fn bid_matches_winning_bid(candidate: &Bid, winning: &Bid) -> bool { - if candidate.slot_id != winning.slot_id || candidate.bidder != winning.bidder { - return false; - } - match winning.ad_id.as_deref() { - Some(winning_ad_id) => candidate.ad_id.as_deref() == Some(winning_ad_id), - None => true, - } -} - fn media_type_for_slot<'a>(request: &'a AuctionRequest, slot_id: &str) -> Option<&'a str> { request .slots @@ -948,6 +939,15 @@ mod tests { } } + fn empty_result(total_time_ms: u64) -> OrchestrationResult { + let mut result = OrchestrationResult::empty( + AuctionTraceContext::new(AuctionSource::AuctionApi), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.total_time_ms = total_time_ms; + result + } + fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { Bid { slot_id: slot_id.to_owned(), @@ -1024,13 +1024,27 @@ mod tests { let provider_error = AuctionResponse::error("mock", 12).with_metadata("error_type", json!("parse_response")); let winning = provider_success.bids[0].clone(); - let result = OrchestrationResult { - provider_responses: vec![provider_success, provider_no_bid, provider_error], - mediator_response: None, - winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), - total_time_ms: 99, - metadata: HashMap::new(), - }; + let mut result = empty_result(99); + result.provider_responses = vec![provider_success, provider_no_bid, provider_error]; + result + .winning_bids + .insert("slot-1".to_owned(), winning.clone()); + result.winning_bid_origins.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + ); + result.trace.winning_bids.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: winning.bidder, + }, + ); let observation = AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/article/1", 1); @@ -1088,13 +1102,8 @@ mod tests { let provider_http_error = AuctionResponse::error("prebid", 12) .with_metadata("error_type", json!("http_status")) .with_metadata("status", json!(403)); - let result = OrchestrationResult { - provider_responses: vec![provider_http_error], - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 12, - metadata: HashMap::new(), - }; + let mut result = empty_result(12); + result.provider_responses = vec![provider_http_error]; let observation = AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/article/1", 1); @@ -1125,13 +1134,28 @@ mod tests { let mediator_bid = bid("slot-1", "kargo", Some("ad-1"), Some(2.0)); let mediator_response = AuctionResponse::success("adserver_mock", vec![mediator_bid.clone()], 15); - let result = OrchestrationResult { - provider_responses: vec![provider_success], - mediator_response: Some(mediator_response), - winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), - total_time_ms: 80, - metadata: HashMap::new(), - }; + let mut result = empty_result(80); + result.provider_responses = vec![provider_success]; + result.mediator_response = Some(mediator_response); + result + .winning_bids + .insert("slot-1".to_owned(), mediator_bid.clone()); + result.winning_bid_origins.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + ); + result.trace.winning_bids.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: mediator_bid.bidder, + }, + ); let observation = AuctionObservationContext::for_test(AuctionSource::InitialNavigation, "/", 1); @@ -1164,13 +1188,7 @@ mod tests { #[test] fn ndjson_serialization_has_one_json_object_per_line_and_no_private_ids() { let request = test_request("ts-ec-derived-id"); - let result = OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 1, - metadata: HashMap::new(), - }; + let result = empty_result(1); let observation = AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1); diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index e4b953e05..45d90731e 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -3,11 +3,18 @@ use std::sync::LazyLock; use edgezero_core::body::Body as EdgeBody; use http::Request; -use super::AuctionContext; +use super::{AuctionContext, AuctionSource}; +use crate::auction::types::AuctionTraceContext; use crate::platform::{RuntimeServices, test_support::noop_services}; use crate::settings::Settings; static TEST_SERVICES: LazyLock = LazyLock::new(noop_services); +static TEST_TRACE: LazyLock = + LazyLock::new(|| AuctionTraceContext::new(AuctionSource::AuctionApi)); + +pub(crate) fn test_trace() -> &'static AuctionTraceContext { + &TEST_TRACE +} pub(crate) fn create_test_auction_context<'a>( settings: &'a Settings, @@ -16,6 +23,7 @@ pub(crate) fn create_test_auction_context<'a>( ) -> AuctionContext<'a> { let services: &'static RuntimeServices = &TEST_SERVICES; AuctionContext { + trace: test_trace(), settings, request, timeout_ms, diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 14c7713f8..a26f9b8c9 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -4,12 +4,157 @@ use edgezero_core::body::Body as EdgeBody; use http::Request; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use uuid::Uuid; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; +/// Source path that initiated an auction candidate. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionSource { + /// Initial publisher navigation using server-side ad templates. + InitialNavigation, + /// SPA navigation through `GET /__ts/page-bids`. + SpaNavigation, + /// Explicit `POST /auction` API. + AuctionApi, +} + +impl AuctionSource { + /// Return the stable wire label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::InitialNavigation => "initial_navigation", + Self::SpaNavigation => "spa_navigation", + Self::AuctionApi => "auction_api", + } + } +} + +/// Privacy-safe public identity for one auction candidate. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, derive_more::Display)] +pub struct AuctionTraceId(Uuid); + +impl AuctionTraceId { + /// Generate a fresh random trace identity. + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + /// Return the underlying UUID. + #[must_use] + pub const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for AuctionTraceId { + fn default() -> Self { + Self::new() + } +} + +/// Privacy-safe public identity for one final winning bid. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, derive_more::Display)] +pub struct BidTraceId(Uuid); + +impl BidTraceId { + /// Generate a fresh random trace identity. + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + #[cfg(test)] + pub(crate) const fn from_uuid(value: Uuid) -> Self { + Self(value) + } +} + +impl Default for BidTraceId { + fn default() -> Self { + Self::new() + } +} + +/// Trace identity and source shared throughout one auction lifecycle. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AuctionTraceContext { + pub auction_trace_id: AuctionTraceId, + pub source: AuctionSource, +} + +impl AuctionTraceContext { + /// Generate a context for an auction candidate. + #[must_use] + pub fn new(source: AuctionSource) -> Self { + Self { + auction_trace_id: AuctionTraceId::new(), + source, + } + } +} + +/// Privacy-safe terminal state exposed to tester traffic. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionPublicOutcome { + Completed, + NoBid, + Skipped, + Failed, + Abandoned, +} + +impl AuctionPublicOutcome { + /// Return the stable wire label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::NoBid => "no_bid", + Self::Skipped => "skipped", + Self::Failed => "failed", + Self::Abandoned => "abandoned", + } + } +} + +/// Result-independent public summary for one auction candidate. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AuctionTraceSummary { + pub auction: AuctionTraceContext, + pub outcome: AuctionPublicOutcome, +} + +/// Public trace metadata for one final winning bid. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct WinningBidTrace { + pub bid_trace_id: BidTraceId, + pub provider: String, + pub bidder: String, +} + +/// Trace data attached to a finalized auction result. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AuctionResultTrace { + pub summary: AuctionTraceSummary, + pub winning_bids: HashMap, +} + +/// Exact internal location of a final winning bid. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct WinningBidOrigin { + pub response_index: usize, + pub bid_index: usize, + pub mediated: bool, +} + /// Represents a unified auction request across all providers. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuctionRequest { @@ -140,6 +285,8 @@ pub struct SiteInfo { /// [dispatch]: crate::auction::AuctionOrchestrator::dispatch_auction /// [collect]: crate::auction::AuctionOrchestrator::collect_dispatched_auction pub struct AuctionContext<'a> { + /// Trace identity owned by the auction entry point. + pub trace: &'a AuctionTraceContext, pub settings: &'a Settings, pub request: &'a Request, pub timeout_ms: u32, diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd747..e5a59cda0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -16,16 +16,18 @@ use validator::{Validate, ValidationError, ValidationErrors}; use crate::ec::registry::PartnerRegistry; 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, + ad_trace::AdTraceConfig, 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, }; use crate::settings::{IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ + "ad_trace", "prebid", "aps", "adserver_mock", @@ -136,6 +138,7 @@ fn validate_enabled_integrations( ) -> Result, Report> { let mut enabled_auction_providers = HashSet::new(); + validate_integration::(settings, "ad_trace")?; if validate_prebid(settings)? { enabled_auction_providers.insert("prebid"); } diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index ffcf4f034..03b5b6d24 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,10 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +/// Host-only browser-session cookie activated by the ad trace console query. +pub const COOKIE_TS_CONSOLE: &str = "__Host-ts-console"; +/// Reserved self-service query parameter for the ad trace console. +pub const QUERY_TS_CONSOLE: &str = "ts_console"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index ad69e51c5..9ef9ee0fd 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -161,6 +161,8 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, + /// Request-scoped console bootstrap injected before the unified bundle. + pub head_bootstrap_script: Option, /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -189,6 +191,7 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + head_bootstrap_script: None, 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, @@ -205,9 +208,11 @@ impl HtmlProcessorConfig { #[must_use] pub fn with_ad_state( mut self, + head_bootstrap_script: Option, ad_slots_script: Option, ad_bids_state: std::sync::Arc>>, ) -> Self { + self.head_bootstrap_script = head_bootstrap_script; self.ad_slots_script = ad_slots_script; self.ad_bids_state = ad_bids_state; self @@ -292,6 +297,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); + let head_bootstrap_script = config.head_bootstrap_script.clone(); let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); @@ -302,10 +308,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integrations = integration_registry.clone(); let patterns = patterns.clone(); let document_state = document_state.clone(); + let head_bootstrap_script = head_bootstrap_script.clone(); let ad_slots_script = ad_slots_script.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // Request-scoped activation must run before every TSJS module. + if let Some(ref bootstrap) = head_bootstrap_script { + snippet.push_str(bootstrap); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -661,6 +672,7 @@ mod tests { request_host: "test.example.com".to_owned(), request_scheme: "https".to_owned(), integrations: IntegrationRegistry::default(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, @@ -738,6 +750,8 @@ mod tests { let html = "Test"; let mut config = create_test_config(); + config.head_bootstrap_script = + Some("".to_owned()); config.integrations = IntegrationRegistry::from_rewriters_with_head_injectors( Vec::new(), Vec::new(), @@ -759,6 +773,7 @@ mod tests { let processed = String::from_utf8(output).expect("output should be valid UTF-8"); let tsjs_marker = "id=\"trustedserver-js\""; + let bootstrap_marker = "window.__tsjs_adTraceActive=true"; let head_marker = "window.__testHeadInjector=true"; assert_eq!( @@ -775,6 +790,9 @@ mod tests { let tsjs_index = processed .find(tsjs_marker) .expect("should include unified tsjs tag"); + let bootstrap_index = processed + .find(bootstrap_marker) + .expect("should include request bootstrap"); let head_index = processed .find(head_marker) .expect("should include head snippet"); @@ -783,8 +801,8 @@ mod tests { .expect("should keep existing head content"); assert!( - head_index < tsjs_index, - "should inject config before tsjs bundle so auto-init can read it" + bootstrap_index < head_index && head_index < tsjs_index, + "should inject request bootstrap and config before tsjs auto-init" ); assert!( tsjs_index < title_index, @@ -1430,6 +1448,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""# .to_string(), @@ -1504,6 +1523,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""#.to_string(), ), @@ -1539,6 +1559,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""#.to_string(), ), @@ -1575,6 +1596,7 @@ mod tests { request_host: request_host.to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, @@ -1625,6 +1647,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""#.to_string(), ), @@ -1653,6 +1676,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/integrations/ad_trace.rs b/crates/trusted-server-core/src/integrations/ad_trace.rs new file mode 100644 index 000000000..41099d4aa --- /dev/null +++ b/crates/trusted-server-core/src/integrations/ad_trace.rs @@ -0,0 +1,620 @@ +//! Query-activated, session-scoped auction trace integration. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use http::{HeaderValue, Method, Request, Response, Uri, header, uri::PathAndQuery}; +use serde::Deserialize; +use validator::Validate; + +use crate::constants::{COOKIE_TS_CONSOLE, QUERY_TS_CONSOLE}; +use crate::error::TrustedServerError; +use crate::http_util::is_navigation_request; +use crate::integrations::IntegrationRegistration; +use crate::settings::{IntegrationConfig, Settings}; + +/// Stable integration identifier. +pub const AD_TRACE_INTEGRATION_ID: &str = "ad_trace"; + +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 optional browser console. +#[derive(Debug, Default, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct AdTraceConfig { + /// Enable the optional ad trace browser module and console activation. + #[serde(default)] + pub enabled: bool, +} + +impl IntegrationConfig for AdTraceConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// Cookie mutation attached to an eligible console-navigation response. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ConsoleCookieAction { + #[default] + None, + SetSession, + ClearSession, +} + +/// Immutable request-scoped console decision. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AdTraceRequestDecision { + enabled: bool, + browser_bootstrap: bool, + private_response: bool, + clean_browser_path_and_query: Option, + cookie_action: ConsoleCookieAction, +} + +impl AdTraceRequestDecision { + /// Whether browser-visible trace fields and targeting are enabled. + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } + + /// Whether this response must be private and non-storeable. + #[must_use] + pub fn requires_private_no_store(&self) -> bool { + self.private_response + || self.cookie_action != ConsoleCookieAction::None + || self.clean_browser_path_and_query.is_some() + } + + /// Build the synchronous bootstrap inserted before the unified TSJS bundle. + #[must_use] + pub fn bootstrap_script(&self) -> Option { + if !self.browser_bootstrap && self.clean_browser_path_and_query.is_none() { + return None; + } + + let mut script = String::from(""); + Some(script) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueryDirective { + Absent, + Enable, + Disable, + Invalid, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ConsoleCookieState { + occurrences: usize, + canonical: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +struct AdTraceCookieApplied; + +/// Register the optional browser module. +/// +/// # Errors +/// +/// Returns a configuration error when the integration settings are invalid. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(_config) = settings.integration_config::(AD_TRACE_INTEGRATION_ID)? + else { + return Ok(None); + }; + Ok(Some( + IntegrationRegistration::builder(AD_TRACE_INTEGRATION_ID).build(), + )) +} + +/// Evaluate and sanitize the console request before routing or downstream use. +/// +/// The original query and cookie are inspected first. Every reserved query pair +/// and console cookie is then removed from the request. The immutable decision +/// is stored in request extensions for handlers to consume after sanitation. +/// +/// # Errors +/// +/// Returns an error when integration configuration or URI reconstruction fails. +pub fn prepare_request( + settings: &Settings, + request: &mut Request, +) -> Result> { + let integration_enabled = settings + .integration_config::(AD_TRACE_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 = is_eligible_console_navigation(request); + + sanitize_console_cookie(request); + if had_reserved_query { + replace_path_and_query(request, &clean_path)?; + } + + let mut decision = AdTraceRequestDecision::default(); + if integration_enabled && eligible_navigation && had_reserved_query { + decision.clean_browser_path_and_query = Some(clean_path); + match directive { + QueryDirective::Enable => { + decision.enabled = true; + decision.browser_bootstrap = true; + decision.cookie_action = ConsoleCookieAction::SetSession; + } + QueryDirective::Disable => { + decision.cookie_action = ConsoleCookieAction::ClearSession; + } + QueryDirective::Invalid | QueryDirective::Absent => {} + } + } else if integration_enabled + && directive == QueryDirective::Absent + && cookie_state.occurrences == 1 + && cookie_state.canonical + { + decision.enabled = true; + decision.browser_bootstrap = eligible_navigation; + } + + decision.private_response = + decision.enabled && trace_payload_request(request, eligible_navigation); + request.extensions_mut().insert(decision.clone()); + Ok(decision) +} + +/// Read the previously prepared request decision. +#[must_use] +pub fn request_decision(request: &Request) -> AdTraceRequestDecision { + request + .extensions() + .get::() + .cloned() + .unwrap_or_default() +} + +/// Return whether browser-visible trace output is active for this request. +#[must_use] +pub fn browser_trace_enabled(request: &Request) -> bool { + request_decision(request).enabled() +} + +/// Copy the prepared request decision onto a response for outer finalization. +pub fn attach_response_decision( + decision: &AdTraceRequestDecision, + response: &mut Response, +) { + response.extensions_mut().insert(decision.clone()); +} + +/// Apply the response-side session mutation and cache policy. +/// +/// Safe to call more than once. The cookie is appended once, while the +/// private/no-store policy is reasserted so later adapter cache policy cannot +/// weaken it. +pub fn finalize_response(response: &mut Response) { + let Some(decision) = response + .extensions() + .get::() + .cloned() + else { + return; + }; + + if decision.cookie_action != ConsoleCookieAction::None + && response + .extensions() + .get::() + .is_none() + { + let value = match decision.cookie_action { + ConsoleCookieAction::None => None, + ConsoleCookieAction::SetSession => Some(HeaderValue::from_static(SET_CONSOLE_COOKIE)), + ConsoleCookieAction::ClearSession => { + Some(HeaderValue::from_static(CLEAR_CONSOLE_COOKIE)) + } + }; + if let Some(value) = value { + response.headers_mut().append(header::SET_COOKIE, value); + response.extensions_mut().insert(AdTraceCookieApplied); + } + } + + if decision.requires_private_no_store() { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + for name in crate::response_privacy::SURROGATE_CACHE_HEADERS { + response.headers_mut().remove(*name); + } + } +} + +fn trace_payload_request(request: &Request, eligible_navigation: bool) -> bool { + eligible_navigation + || request.uri().path() == "/auction" + || request.uri().path() == "/__ts/page-bids" +} + +fn is_eligible_console_navigation(request: &Request) -> bool { + request.method() == Method::GET + && is_navigation_request(request) + && !crate::publisher::is_prefetch_request(request) + && !crate::publisher::is_bot_user_agent(request) +} + +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 == QUERY_TS_CONSOLE { + 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() == COOKIE_TS_CONSOLE => { + state.occurrences += 1; + state.canonical |= value.trim() == "1"; + } + None if cookie == COOKIE_TS_CONSOLE => 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() != COOKIE_TS_CONSOLE, + None => *cookie != COOKIE_TS_CONSOLE, + }) + .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: "ad trace console query produced invalid URI".to_owned(), + })?, + ); + *request.uri_mut() = Uri::from_parts(parts).change_context(TrustedServerError::Proxy { + message: "ad trace console query produced invalid URI".to_owned(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use http::{Request, Response, header}; + + use crate::test_support::tests::create_test_settings; + + use super::*; + + fn settings(enabled: bool) -> Settings { + let mut settings = create_test_settings(); + settings.integrations.insert( + AD_TRACE_INTEGRATION_ID.to_owned(), + serde_json::json!({ "enabled": enabled }), + ); + settings + } + + fn request(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 rejects_unknown_gate_configuration() { + let mut settings = create_test_settings(); + settings.integrations.insert( + AD_TRACE_INTEGRATION_ID.to_owned(), + serde_json::json!({ "enabled": true, "enabledd": true }), + ); + + let error = settings + .integration_config::(AD_TRACE_INTEGRATION_ID) + .expect_err("should reject unknown gate field"); + assert!( + error.to_string().contains("could not be parsed"), + "should reject invalid configuration: {error}" + ); + } + + #[test] + fn query_enables_first_response_and_sanitizes_request() { + let mut req = request( + "https://publisher.example/page?x=%2F&ts_console=1&y=2", + Some("session=abc; __Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + + assert!(decision.enabled()); + assert_eq!(decision.cookie_action, ConsoleCookieAction::SetSession); + assert_eq!( + req.uri().to_string(), + "https://publisher.example/page?x=%2F&y=2" + ); + assert_eq!( + req.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookie"), + "session=abc" + ); + assert!(browser_trace_enabled(&req)); + let script = decision.bootstrap_script().expect("should bootstrap"); + assert!(script.contains("__tsjs_adTraceActive=true")); + assert!(script.contains("/page?x=%2F&y=2")); + + let mut separators = request( + "https://publisher.example/page?a=1&&ts_console=1&b=2&", + None, + ); + prepare_request(&settings(true), &mut separators).expect("should prepare"); + assert_eq!(separators.uri().query(), Some("a=1&&b=2&")); + } + + #[test] + fn exact_enable_and_disable_values_are_supported() { + for value in ["true", "1"] { + let mut req = request( + &format!("https://publisher.example/?ts_console={value}"), + None, + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(decision.enabled(), "{value} should enable"); + assert_eq!(decision.cookie_action, ConsoleCookieAction::SetSession); + } + for value in ["false", "0"] { + let mut req = request( + &format!("https://publisher.example/?ts_console={value}"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled(), "{value} should disable"); + assert_eq!(decision.cookie_action, ConsoleCookieAction::ClearSession); + } + } + + #[test] + fn invalid_or_duplicate_query_fails_closed_without_cookie_mutation() { + for query in [ + "ts_console=True", + "ts_console=", + "ts_console=1&ts_console=true", + ] { + let mut req = request( + &format!("https://publisher.example/?{query}&keep=1"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled(), "{query} should fail closed"); + assert_eq!(decision.cookie_action, ConsoleCookieAction::None); + assert_eq!(req.uri().query(), Some("keep=1")); + } + } + + #[test] + fn disabled_config_sanitizes_but_never_activates() { + let mut req = request( + "https://publisher.example/?ts_console=1&keep=1", + Some("__Host-ts-console=1; other=value; ts-tester=true"), + ); + let decision = prepare_request(&settings(false), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!(decision.cookie_action, ConsoleCookieAction::None); + assert_eq!(decision.clean_browser_path_and_query, None); + assert_eq!(req.uri().query(), Some("keep=1")); + assert_eq!( + req.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookies"), + "other=value; ts-tester=true" + ); + } + + #[test] + fn exact_session_cookie_gates_api_but_query_cannot_activate_it() { + let mut active = Request::builder() + .method(Method::POST) + .uri("https://publisher.example/auction") + .header(header::COOKIE, "__Host-ts-console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + assert!( + prepare_request(&settings(true), &mut active) + .expect("should prepare") + .enabled() + ); + + let mut query_only = Request::builder() + .method(Method::POST) + .uri("https://publisher.example/auction?ts_console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + let decision = prepare_request(&settings(true), &mut query_only).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!(decision.cookie_action, ConsoleCookieAction::None); + assert_eq!(query_only.uri().query(), None); + } + + #[test] + fn active_session_does_not_make_static_bundle_response_private() { + let mut req = Request::builder() + .method(Method::GET) + .uri("https://publisher.example/static/tsjs=tsjs-unified.min.js") + .header("sec-fetch-dest", "script") + .header(header::COOKIE, "__Host-ts-console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(decision.enabled()); + assert!(!decision.requires_private_no_store()); + assert_eq!(decision.bootstrap_script(), None); + } + + #[test] + fn invalid_api_query_fails_closed_even_with_session_cookie() { + let mut req = Request::builder() + .method(Method::POST) + .uri("https://publisher.example/auction?ts_console=invalid") + .header(header::COOKIE, "__Host-ts-console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!(req.uri().query(), None); + assert!(!req.headers().contains_key(header::COOKIE)); + } + + #[test] + fn duplicate_console_cookie_fails_closed_and_all_copies_are_removed() { + let mut req = request( + "https://publisher.example/", + Some("__Host-ts-console=1; a=b; __Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!( + req.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookie"), + "a=b" + ); + + let mut bare = request( + "https://publisher.example/", + Some("__Host-ts-console=1; __Host-ts-console; a=b"), + ); + let decision = prepare_request(&settings(true), &mut bare).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!( + bare.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookie"), + "a=b" + ); + } + + #[test] + fn ts_tester_cookie_no_longer_activates_console() { + let mut req = request("https://publisher.example/", Some("ts-tester=true")); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + } + + #[test] + fn response_finalization_appends_cookie_once_and_reasserts_no_store() { + let mut req = request("https://publisher.example/?ts_console=1", None); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + let mut response = Response::builder() + .header(header::SET_COOKIE, "existing=value") + .header(header::CACHE_CONTROL, "public, max-age=60") + .header("surrogate-control", "max-age=60") + .header("cloudflare-cdn-cache-control", "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + attach_response_decision(&decision, &mut response); + + finalize_response(&mut response); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=60"), + ); + finalize_response(&mut response); + + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert!(!response.headers().contains_key("surrogate-control")); + assert!( + !response + .headers() + .contains_key("cloudflare-cdn-cache-control") + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index cc4c5c00c..884aa435a 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -42,6 +42,48 @@ pubads.__tsInitialLoadHooked = true; }); + function captureRequest(slot, trigger) { + function firstTarget(key) { + if (!slot || typeof slot.getTargeting !== "function") return undefined; + var values = slot.getTargeting(key); + return values && values.length ? String(values[0]) : undefined; + } + var divId = + slot && typeof slot.getSlotElementId === "function" + ? slot.getSlotElementId() + : ""; + var slotId = (ts.divToSlotId || {})[divId]; + var liveBid = slotId && ts.bids ? ts.bids[slotId] : undefined; + var bidSnapshot = liveBid + ? Object.freeze( + Object.assign({}, liveBid, { + trace: liveBid.trace ? Object.freeze(Object.assign({}, liveBid.trace)) : undefined, + }), + ) + : undefined; + // Freeze request-boundary attribution before display()/refresh(). If the + // optional module loads later, draining this queue never rereads mutable GPT + // targeting or the current route's bid object. + var snapshot = Object.freeze({ + slotId: slotId, + bidder: firstTarget("hb_bidder"), + adId: firstTarget("hb_adid"), + traceToken: firstTarget("ts_trace"), + bid: bidSnapshot, + }); + if (typeof ts.captureAdTraceRequest === "function") { + ts.captureAdTraceRequest(slot, trigger, snapshot); + return; + } + // The unified bundle may load after this bootstrap. Queue private request + // ownership unconditionally so trace-off traffic receives the same stale + // render and billing protection; diagnostic fields remain independently gated. + ts.pendingAdTraceRequests = ts.pendingAdTraceRequests || []; + if (ts.pendingAdTraceRequests.length < 64) { + ts.pendingAdTraceRequests.push({ slot: slot, trigger: trigger, snapshot: snapshot }); + } + } + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -111,6 +153,9 @@ ].forEach(function (k) { if (b[k]) s.setTargeting(k, b[k]); }); + if (b.trace && b.trace.bidTraceId) { + s.setTargeting("ts_trace", b.trace.bidTraceId); + } // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); // Map both the inner div and the GPT slot's element ID (the @@ -143,6 +188,12 @@ // impression. Runs after enableServices(); on SPA navigation services are // already enabled, so this runs unconditionally for new slots. slotsToDisplay.forEach(function (divId) { + var requestSlot = newSlots.find(function (slot) { + return slot.getSlotElementId() === divId; + }); + if (requestSlot && !ts.gptInitialLoadDisabled) { + captureRequest(requestSlot, "bootstrap_display"); + } googletag.display(divId); }); // Reused publisher-owned slots always need a refresh to pick up the @@ -161,6 +212,9 @@ // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. ts.adInitRefreshInProgress = true; try { + slotsNeedingRefresh.forEach(function (slot) { + captureRequest(slot, "bootstrap_refresh"); + }); googletag.pubads().refresh(slotsNeedingRefresh); } finally { ts.adInitRefreshInProgress = false; diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index af56c3713..3431bc8e7 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -11,6 +11,7 @@ use crate::error::TrustedServerError; use crate::platform::{DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, RuntimeServices}; use crate::settings::Settings; +pub mod ad_trace; pub mod adserver_mock; pub mod aps; pub mod datadome; @@ -284,6 +285,10 @@ pub(crate) struct IntegrationBuilder { pub(crate) fn builders() -> &'static [IntegrationBuilder] { &[ + IntegrationBuilder { + id: "ad_trace", + build: ad_trace::register, + }, IntegrationBuilder { id: "prebid", build: prebid::register, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a1b40281d..fc2812a87 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2664,6 +2664,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &http_req, timeout_ms: 500, @@ -2707,6 +2708,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &http_req, timeout_ms: 500, @@ -2728,6 +2730,11 @@ mod tests { request_host, auction_request.publisher.domain, "request_host should be the publisher domain, not the edge Host header" ); + assert!( + !String::from_utf8_lossy(&bodies[0]) + .contains(&context.trace.auction_trace_id.to_string()), + "internal trace UUID should never be serialized upstream" + ); } fn create_test_auction_context<'a>( @@ -5371,13 +5378,14 @@ external_bundle_sri = "sha384-AAAA" prebid_platform_response(StatusCode::BAD_REQUEST, Some("application/json"), body); let provider_response = futures::executor::block_on(provider.parse_response(response, 42)) .expect("should classify upstream HTTP error"); - let result = OrchestrationResult { - provider_responses: vec![provider_response], - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 42, - metadata: HashMap::new(), - }; + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new( + crate::auction::types::AuctionSource::AuctionApi, + ), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.provider_responses = vec![provider_response]; + result.total_time_ms = 42; let response = convert_to_openrtb_response( &result, &make_settings(), @@ -5494,6 +5502,7 @@ external_bundle_sri = "sha384-AAAA" .expect("should build request"); let services = noop_services(); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &http_req, timeout_ms: 1000, diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 65237ce0e..a06a41183 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -174,10 +174,51 @@ pub struct ImpStoredRequest { #[derive(Debug, Serialize)] pub struct ResponseExt { pub orchestrator: OrchestratorExt, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_server: Option, } impl ToExt for ResponseExt {} +/// Namespaced Trusted Server response extensions. +#[derive(Debug, Serialize)] +pub struct TrustedServerResponseExt { + pub trace: AuctionTraceWire, +} + +/// Privacy-safe root trace extension. +#[derive(Debug, Serialize)] +pub struct AuctionTraceWire { + pub version: u8, + pub auction_trace_id: String, + pub source: &'static str, + pub outcome: &'static str, +} + +/// Namespaced Trusted Server bid extensions. +#[derive(Debug, Serialize)] +pub struct TrustedServerBidExt { + pub trusted_server: TrustedServerBidTraceContainer, +} + +impl ToExt for TrustedServerBidExt {} + +/// Container for a Trusted Server bid trace. +#[derive(Debug, Serialize)] +pub struct TrustedServerBidTraceContainer { + pub trace: BidTraceWire, +} + +/// Privacy-safe final-winning-bid trace extension. +#[derive(Debug, Serialize)] +pub struct BidTraceWire { + pub version: u8, + pub bid_trace_id: String, + pub slot_id: String, + pub provider: String, + pub bidder: String, +} + #[cfg(test)] mod tests { use super::*; @@ -211,6 +252,7 @@ mod tests { time_ms: 12, provider_details: vec![], }, + trusted_server: None, } .to_ext(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 34909efe7..c4e64b29b 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -31,7 +31,7 @@ use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; use crate::auction::orchestrator::{ - AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, + AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, OrchestrationResult, }; use crate::auction::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, @@ -229,6 +229,7 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + head_bootstrap_script: Option<&'a str>, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, } @@ -273,8 +274,11 @@ fn process_response_streaming( params.request_scheme, params.settings, params.integration_registry, - params.ad_slots_script.map(str::to_string), - params.ad_bids_state.clone(), + HtmlAdState { + head_bootstrap_script: params.head_bootstrap_script.map(str::to_string), + ad_slots_script: params.ad_slots_script.map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + }, )?; StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; } else if is_rsc_flight { @@ -312,14 +316,19 @@ fn process_response_streaming( /// `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. +struct HtmlAdState { + head_bootstrap_script: Option, + ad_slots_script: Option, + ad_bids_state: Arc>>, +} + fn create_html_stream_processor( origin_host: &str, request_host: &str, request_scheme: &str, settings: &Settings, integration_registry: &IntegrationRegistry, - ad_slots_script: Option, - ad_bids_state: Arc>>, + ad_state: HtmlAdState, ) -> Result, Report> { use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; @@ -330,7 +339,11 @@ fn create_html_stream_processor( request_host, request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state( + ad_state.head_bootstrap_script, + ad_state.ad_slots_script, + ad_state.ad_bids_state, + ); Ok(create_html_processor(config)) } @@ -441,6 +454,7 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) head_bootstrap_script: Option, pub(crate) ad_slots_script: Option, pub(crate) ad_bids_state: Arc>>, /// Observation context for the in-flight auction. @@ -453,6 +467,8 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, + /// Whether the config and exact tester cookie permit browser trace output. + pub(crate) ad_trace_enabled: bool, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -620,6 +636,7 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + head_bootstrap_script: params.head_bootstrap_script.as_deref(), ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: ¶ms.ad_bids_state, }; @@ -671,11 +688,12 @@ pub async fn stream_publisher_body_async( // Non-HTML: collect auction first, then stream. There is no // to hold, so delaying the entire body until collection is acceptable. let placeholder = mediator_placeholder_request(); + let trace = dispatched.trace().clone(); let result = orchestrator .collect_dispatched_auction( dispatched, services, - &make_collect_context(settings, services, &placeholder), + &make_collect_context(&trace, settings, services, &placeholder), ) .await; if let (Some(observation), Some(auction_request)) = @@ -694,10 +712,11 @@ pub async fn stream_publisher_body_async( } write_bids_to_state( - &result.winning_bids, + &result, params.price_granularity, ¶ms.ad_bids_state, settings.debug.inject_adm_for_testing, + params.ad_trace_enabled, ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -711,8 +730,11 @@ pub async fn stream_publisher_body_async( ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - params.ad_bids_state.clone(), + HtmlAdState { + head_bootstrap_script: params.head_bootstrap_script.as_deref().map(str::to_string), + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + }, ) { Ok(processor) => processor, Err(err) => { @@ -741,6 +763,7 @@ pub async fn stream_publisher_body_async( orchestrator, services, settings, + trace_enabled: params.ad_trace_enabled, }, ) .await @@ -769,6 +792,7 @@ fn mediator_placeholder_request() -> Request { /// this argument is plumbing for the (presently unused) case where the /// orchestrator needs the caller's request shape. fn make_collect_context<'a>( + trace: &'a crate::auction::types::AuctionTraceContext, settings: &'a Settings, services: &'a RuntimeServices, placeholder: &'a Request, @@ -780,6 +804,7 @@ fn make_collect_context<'a>( callers must not forward a real client request through the collect path" ); AuctionContext { + trace, settings, request: placeholder, timeout_ms: 0, @@ -845,18 +870,27 @@ pub(crate) fn should_run_server_side_ad_stack( /// Write winning bids from an auction result into the shared `ad_bids_state` lock. pub(crate) fn write_bids_to_state( - winning_bids: &std::collections::HashMap, + result: &crate::auction::orchestrator::OrchestrationResult, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, inject_adm: bool, + trace_enabled: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", - winning_bids.len(), - winning_bids.keys().cloned().collect::>().join(", ") + result.winning_bids.len(), + result + .winning_bids + .keys() + .cloned() + .collect::>() + .join(", ") + ); + let bid_map = build_bid_map_with_trace(result, price_granularity, inject_adm, trace_enabled); + let bids_script = build_bids_script_with_trace( + &bid_map, + trace_enabled.then(|| auction_trace_json(&result.trace.summary)), ); - let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); - let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1065,6 +1099,7 @@ struct AuctionCollectCtx<'a> { orchestrator: &'a AuctionOrchestrator, services: &'a RuntimeServices, settings: &'a Settings, + trace_enabled: bool, } /// Run the close-body hold loop for HTML bodies, collecting the auction before @@ -1193,6 +1228,7 @@ async fn body_close_hold_loop( orchestrator, services, settings, + trace_enabled, } = ctx; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; let mut hold = Some(BodyCloseHoldBuffer::new()); @@ -1208,11 +1244,14 @@ async fn body_close_hold_loop( collect_stream_auction( dispatched, telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, + StreamAuctionFinalizeContext { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + trace_enabled, + }, ) .await; @@ -1271,11 +1310,14 @@ async fn body_close_hold_loop( collect_stream_auction( dispatched, telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, + StreamAuctionFinalizeContext { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + trace_enabled, + }, ) .await; @@ -1351,18 +1393,32 @@ async fn emit_abandoned_auction( .await; } +struct StreamAuctionFinalizeContext<'a> { + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, + trace_enabled: bool, +} + async fn collect_stream_auction( dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry, - price_granularity: PriceGranularity, - ad_bids_state: &Arc>>, - orchestrator: &AuctionOrchestrator, - services: &RuntimeServices, - settings: &Settings, + context: StreamAuctionFinalizeContext<'_>, ) { + let StreamAuctionFinalizeContext { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + trace_enabled, + } = context; log::info!("body_close_hold_loop: collecting dispatched auction before held body tail"); let placeholder = mediator_placeholder_request(); - let collect_ctx = make_collect_context(settings, services, &placeholder); + let trace = dispatched.trace().clone(); + let collect_ctx = make_collect_context(&trace, settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; @@ -1385,10 +1441,11 @@ async fn collect_stream_auction( result.winning_bids.len() ); write_bids_to_state( - &result.winning_bids, + &result, price_granularity, ad_bids_state, settings.debug.inject_adm_for_testing, + trace_enabled, ); if settings.debug.auction_html_comment { @@ -1493,6 +1550,9 @@ pub async fn handle_publisher_request( ); let consent_context = ec_context.consent().clone(); + let ad_trace_decision = crate::integrations::ad_trace::request_decision(&req); + let ad_trace_enabled = ad_trace_decision.enabled(); + let ad_trace_bootstrap = ad_trace_decision.bootstrap_script(); let ec_id = ec_context.ec_value().filter(|_| ec_allowed); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); @@ -1609,13 +1669,15 @@ pub async fn handle_publisher_request( let mut dispatched_auction = if matched_slots.is_empty() { None } else { + let trace = + crate::auction::types::AuctionTraceContext::new(AuctionSource::InitialNavigation); // Telemetry attribution must use the same publisher identity as the // outbound bid request. On the navigation path `request_host` is the // trusted-server edge host, so using it here would attribute navigation // rows to the edge/staging domain while `/auction` rows (built from // `AuctionRequest::publisher.domain`) use the configured domain. let observation = AuctionObservationContext::from_parts( - AuctionSource::InitialNavigation, + &trace, &settings.publisher.domain, &request_path, matched_slots.len(), @@ -1651,6 +1713,7 @@ pub async fn handle_publisher_request( }, ); let auction_context = AuctionContext { + trace: &trace, settings, request: &req, timeout_ms: auction_timeout_ms, @@ -1672,6 +1735,19 @@ pub async fn handle_publisher_request( provider_responses, elapsed_ms, } => { + if ad_trace_enabled { + let terminal = OrchestrationResult::empty( + trace.clone(), + crate::auction::types::AuctionPublicOutcome::Failed, + ); + write_bids_to_state( + &terminal, + price_granularity, + &ad_bids_state, + settings.debug.inject_adm_for_testing, + true, + ); + } emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -1687,6 +1763,19 @@ pub async fn handle_publisher_request( None } DispatchAuctionOutcome::NotStarted => { + if ad_trace_enabled { + let terminal = OrchestrationResult::empty( + trace.clone(), + crate::auction::types::AuctionPublicOutcome::Failed, + ); + write_bids_to_state( + &terminal, + price_granularity, + &ad_bids_state, + settings.debug.inject_adm_for_testing, + true, + ); + } let elapsed_ms = observation.elapsed_ms(); emit_auction_events_best_effort_lazy(services, || { build_auction_events( @@ -1921,12 +2010,14 @@ pub async fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + head_bootstrap_script: ad_trace_bootstrap.clone(), ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), auction_observation, auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + ad_trace_enabled, }), }) } @@ -2172,18 +2263,78 @@ pub(crate) fn build_bid_map( .collect() } +fn auction_trace_json(summary: &crate::auction::types::AuctionTraceSummary) -> serde_json::Value { + serde_json::json!({ + "version": 1, + "auctionTraceId": summary.auction.auction_trace_id.to_string(), + "source": summary.auction.source.as_str(), + "outcome": summary.outcome.as_str(), + }) +} + +fn apply_bid_traces( + bid_map: &mut serde_json::Map, + result_trace: &crate::auction::types::AuctionResultTrace, +) { + for (slot_id, trace) in &result_trace.winning_bids { + if let Some(serde_json::Value::Object(bid)) = bid_map.get_mut(slot_id) { + bid.insert( + "trace".to_owned(), + serde_json::json!({ + "version": 1, + "auctionTraceId": result_trace.summary.auction.auction_trace_id.to_string(), + "bidTraceId": trace.bid_trace_id.to_string(), + "source": result_trace.summary.auction.source.as_str(), + "slotId": slot_id, + "provider": trace.provider, + "bidder": trace.bidder, + }), + ); + } + } +} + +fn build_bid_map_with_trace( + result: &crate::auction::orchestrator::OrchestrationResult, + granularity: crate::price_bucket::PriceGranularity, + include_adm: bool, + trace_enabled: bool, +) -> serde_json::Map { + let mut bid_map = build_bid_map(&result.winning_bids, granularity, include_adm); + if !trace_enabled { + return bid_map; + } + apply_bid_traces(&mut bid_map, &result.trace); + bid_map +} + /// Build the `tsjs.bids` `` sequences inside the string. pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { + build_bids_script_with_trace(bid_map, None) +} + +fn build_bids_script_with_trace( + bid_map: &serde_json::Map, + auction_trace: Option, +) -> String { let json = serde_json::to_string(bid_map) .expect("serde_json::to_string of Map should be infallible"); let escaped = html_escape_for_script(&json); - format!( - "", - escaped - ) + if let Some(trace) = auction_trace { + let trace_json = serde_json::to_string(&trace) + .expect("serde_json::to_string of trace should be infallible"); + let escaped_trace = html_escape_for_script(&trace_json); + format!( + "" + ) + } else { + format!( + "" + ) + } } /// Build the empty-bids `"# .to_string(), @@ -4015,6 +4195,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); @@ -4058,12 +4239,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -4165,12 +4348,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -4221,12 +4406,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); @@ -4258,7 +4445,7 @@ mod tests { mod creative_opportunities_tests { use super::super::{ MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, - build_bids_script, html_escape_for_script, + build_bids_script, build_bids_script_with_trace, html_escape_for_script, }; use crate::auction::types::{Bid, MediaType}; use crate::consent::ConsentContext; @@ -4688,6 +4875,30 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in bids script"); } + #[test] + fn traced_bids_script_assigns_summary_and_bids_before_ad_init() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let trace = serde_json::json!({ + "version": 1, + "auctionTraceId": "550e8400-e29b-41d4-a716-446655440000", + "source": "initial_navigation", + "outcome": "completed", + }); + + let script = build_bids_script_with_trace(&map, Some(trace)); + + let trace_pos = script + .find(".auctionTrace=JSON.parse") + .expect("should assign trace"); + let bids_pos = script.find(".bids=JSON.parse").expect("should assign bids"); + let init_pos = script.find("adInit").expect("should invoke adInit"); + assert!( + trace_pos < bids_pos && bids_pos < init_pos, + "should atomically assign trace and bids before adInit" + ); + } + #[test] fn bids_script_calls_ad_init_without_retry_timer() { let mut map = serde_json::Map::new(); @@ -4982,8 +5193,10 @@ mod tests { orchestrator: &AuctionOrchestrator, slots: &[CreativeOpportunitySlot], ec_context: &EcContext, - req: Request, + mut req: Request, ) -> Response { + crate::integrations::ad_trace::prepare_request(settings, &mut req) + .expect("should prepare ad trace request"); let services = noop_services(); handle_page_bids( settings, @@ -5167,11 +5380,17 @@ mod tests { #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { - // Slots exist but request path does not match — no auction, no injection. - let settings = settings_with_co(); + // Slots exist but request path does not match — no auction, no injection, + // and no unjoinable trace identity even when the tester gate is open. + let mut settings = settings_with_co(); + settings + .integrations + .insert_config("ad_trace", &serde_json::json!({ "enabled": true })) + .expect("should configure ad trace"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let slots = article_slot(); // slot matches /20** only - let req = make_page_bids_request("/about"); // does not match + let mut req = make_page_bids_request("/about"); // does not match + set_test_header(&mut req, "cookie", "__Host-ts-console=1"); let body = run_page_bids(&settings, &orchestrator, &slots, req).await; @@ -5191,6 +5410,45 @@ mod tests { 0, "non-matching URL should produce zero bids" ); + assert!( + body.get("auctionTrace").is_none(), + "non-matching URL should not expose an identity without telemetry" + ); + } + + #[tokio::test] + async fn page_bids_trace_requires_config_and_console_session() { + let mut settings = settings_with_co_auction_disabled(); + settings + .integrations + .insert_config("ad_trace", &serde_json::json!({ "enabled": true })) + .expect("should configure ad trace"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + + let without_cookie = make_page_bids_request("/2024/01/my-article/"); + let without_cookie_body = + run_page_bids_consent_allowed(&settings, &orchestrator, &slots, without_cookie) + .await; + assert!( + without_cookie_body.get("auctionTrace").is_none(), + "config alone should not expose trace" + ); + + let mut gated = make_page_bids_request("/2024/01/my-article/"); + set_test_header(&mut gated, "cookie", "__Host-ts-console=1"); + let gated_body = + run_page_bids_consent_allowed(&settings, &orchestrator, &slots, gated).await; + assert_eq!( + gated_body["auctionTrace"]["source"], + serde_json::json!("spa_navigation"), + "both gates should expose generic SPA trace" + ); + assert_eq!( + gated_body["auctionTrace"]["outcome"], + serde_json::json!("skipped"), + "disabled auction should not be fabricated as completed no-bid" + ); } #[test] diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 27a94b62d..262d3fc95 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -17,7 +17,12 @@ 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", + "cdn-cache-control", + "cloudflare-cdn-cache-control", +]; /// Forces cookie-bearing responses to stay private to shared caches. /// @@ -82,8 +87,9 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: for (key, value) in &settings.response_headers { 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")) + || SURROGATE_CACHE_HEADERS + .iter() + .any(|name| key.eq_ignore_ascii_case(name))) { continue; } diff --git a/crates/trusted-server-integration-tests/browser/global-setup.ts b/crates/trusted-server-integration-tests/browser/global-setup.ts index f54d92dbe..14b3b0539 100644 --- a/crates/trusted-server-integration-tests/browser/global-setup.ts +++ b/crates/trusted-server-integration-tests/browser/global-setup.ts @@ -16,12 +16,11 @@ const WASM_PATH = "../../../target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm", ); -const VICEROY_CONFIG = - process.env.VICEROY_CONFIG_PATH || - resolve( - __dirname, - "../../../target/integration-test-artifacts/configs/viceroy.toml", - ); +function viceroyConfigPath(framework: string): string { + if (process.env.VICEROY_CONFIG_PATH) return process.env.VICEROY_CONFIG_PATH; + const filename = framework === "ad-trace" ? "viceroy-ad-trace.toml" : "viceroy.toml"; + return resolve(__dirname, `../../../target/integration-test-artifacts/configs/${filename}`); +} /** Persist current state so global-teardown can always clean up. */ function writeState(state: { @@ -47,7 +46,7 @@ async function globalSetup(): Promise { writeState({ containerId, framework }); console.log(`[global-setup] Starting Viceroy (WASM: ${WASM_PATH})...`); - const viceroy = await startViceroy(WASM_PATH, VICEROY_CONFIG); + const viceroy = await startViceroy(WASM_PATH, viceroyConfigPath(framework)); viceroyPid = viceroy.process.pid; console.log(`[global-setup] Viceroy ready at ${viceroy.baseUrl}`); diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index 0402bb266..1b7682b6b 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -7,6 +7,7 @@ const ORIGIN_PORT = process.env.INTEGRATION_ORIGIN_PORT || "8888"; /** Framework-specific container configuration. */ const FRAMEWORK_CONFIG: Record = { + "ad-trace": { image: "test-ad-trace:latest", port: 80 }, nextjs: { image: "test-nextjs:latest", port: 3000 }, wordpress: { image: "test-wordpress:latest", port: 80 }, }; diff --git a/crates/trusted-server-integration-tests/browser/helpers/state.ts b/crates/trusted-server-integration-tests/browser/helpers/state.ts index b8f5d4b66..dd655d01c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/state.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/state.ts @@ -8,7 +8,7 @@ export interface TestState { framework: string; } -const KNOWN_FRAMEWORKS = ["nextjs", "wordpress"] as const; +const KNOWN_FRAMEWORKS = ["ad-trace", "nextjs", "wordpress"] as const; const STATE_FILE = resolve(__dirname, "../.browser-test-state.json"); let cachedState: TestState | undefined; diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index 13282f289..72b3fb997 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "test": "npx playwright test", + "test:ad-trace": "TEST_FRAMEWORK=ad-trace npx playwright test tests/ad-trace/auction-trace.spec.ts", "test:nextjs": "TEST_FRAMEWORK=nextjs npx playwright test", "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test" }, diff --git a/crates/trusted-server-integration-tests/browser/playwright.config.ts b/crates/trusted-server-integration-tests/browser/playwright.config.ts index 8a1ef3b5b..812c889ec 100644 --- a/crates/trusted-server-integration-tests/browser/playwright.config.ts +++ b/crates/trusted-server-integration-tests/browser/playwright.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from "@playwright/test"; +const framework = process.env.TEST_FRAMEWORK || "nextjs"; + export default defineConfig({ testDir: "./tests", + testMatch: + framework === "ad-trace" + ? ["ad-trace/**/*.spec.ts"] + : ["nextjs/**/*.spec.ts", "shared/**/*.spec.ts", "wordpress/**/*.spec.ts"], globalSetup: "./global-setup.ts", globalTeardown: "./global-teardown.ts", timeout: 30_000, @@ -20,5 +26,5 @@ export default defineConfig({ }, ], reporter: [["list"], ["html", { open: "never" }]], - outputDir: "./test-results", + outputDir: `./test-results-${framework}`, }); diff --git a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts new file mode 100644 index 000000000..9c2935d07 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts @@ -0,0 +1,439 @@ +import { expect, test, type Page } from "@playwright/test"; +import { runtimeUrl } from "../../helpers/state.js"; + +const ORIGIN_PORT = process.env.INTEGRATION_ORIGIN_PORT || "8888"; + +async function serveBuiltPrebid(page: Page): Promise { + const response = await fetch( + `http://127.0.0.1:${ORIGIN_PORT}/prebid-bundle.js`, + ); + if (!response.ok) + throw new Error(`fixture Prebid bundle returned ${response.status}`); + const body = await response.text(); + await page.route("**/integrations/prebid/bundle.js*", (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript", + body, + }), + ); +} + +async function openTesterPage(page: Page): Promise { + await serveBuiltPrebid(page); + await page.goto(runtimeUrl("/?ts_console=1"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/")); + await expect + .poll(() => + page.evaluate(() => + ( + window as Window & { + tsjs?: { adTrace?: { export(): unknown } }; + } + ).tsjs?.adTrace?.export(), + ), + ) + .toBeTruthy(); + await expect + .poll(() => + page.evaluate(() => { + const result = ( + window as Window & { + tsjs?: { + adTrace?: { + export(): { + slots: Array<{ + slotId: string; + stages: { + creative: { outcome: string }; + }; + }>; + }; + }; + }; + } + ).tsjs?.adTrace?.export(); + return result?.slots.find( + (slot) => slot.slotId === "ad-trace-slot", + )?.stages.creative.outcome; + }), + ) + .toBe("load_acknowledged"); + await expect + .poll(() => + page.evaluate(() => + ( + window as Window & { + tsjs?: { + adTrace?: { + getEvents(): Array<{ kind: string }>; + }; + }; + } + ).tsjs?.adTrace + ?.getEvents() + .some((event) => event.kind === "gpt_slot_render_ended"), + ), + ) + .toBe(true); +} + +async function exported(page: Page) { + return page.evaluate(() => + ( + window as Window & { + tsjs: { + adTrace: { + export(): { slots: Array> }; + }; + }; + } + ).tsjs.adTrace.export(), + ); +} + +test.describe("tester-only auction trace contract", () => { + test("config without an activated console session exposes no browser trace surface", async ({ + page, + }) => { + await serveBuiltPrebid(page); + await page.goto(runtimeUrl("/"), { waitUntil: "domcontentloaded" }); + + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("undefined"); + }); + + test("console session supports true, persists privately, and can be disabled", async ({ + page, + }) => { + await serveBuiltPrebid(page); + const activation = await page.goto(runtimeUrl("/?ts_console=true"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/")); + expect(activation?.headers()["cache-control"]).toBe("private, no-store"); + await expect + .poll(() => + page.evaluate( + () => + typeof ( + window as Window & { tsjs?: { adTrace?: unknown } } + ).tsjs?.adTrace, + ), + ) + .toBe("object"); + expect( + (await page.context().cookies()).find( + (cookie) => cookie.name === "__Host-ts-console", + ), + ).toMatchObject({ + value: "1", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + + await page.reload({ waitUntil: "domcontentloaded" }); + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("object"); + + await page.goto(runtimeUrl("/?ts_console=0"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/")); + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("undefined"); + + await page.reload({ waitUntil: "domcontentloaded" }); + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("undefined"); + }); + + test("initial TS winner reaches direct GPT and source-validated creative acknowledgement", async ({ + page, + }) => { + await openTesterPage(page); + + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { + stages?: Record< + string, + { outcome?: string; confidence?: string } + >; + } + | undefined; + return { + trustedServer: slot?.stages?.trustedServer?.outcome, + prebid: slot?.stages?.prebid?.outcome, + gam: slot?.stages?.gam?.outcome, + creative: slot?.stages?.creative?.outcome, + }; + }) + .toEqual({ + trustedServer: "won", + prebid: "not_run", + gam: "trusted_server_won", + creative: "load_acknowledged", + }); + + const session = await page.context().newCDPSession(page); + const tree = (await session.send("Accessibility.getFullAXTree")) as { + nodes: Array<{ name?: { value?: string } }>; + }; + const visibleText = tree.nodes + .map((node) => node.name?.value || "") + .join("\n"); + expect(visibleText).toContain("TS winner: won · definitive"); + expect(visibleText).toContain( + "Creative: load_acknowledged · definitive", + ); + }); + + test("direct auction API render reaches an exact iframe-load acknowledgement", async ({ + page, + }) => { + await openTesterPage(page); + await page.evaluate(() => { + const direct = document.createElement("div"); + direct.id = "direct-api-slot"; + document.body.appendChild(direct); + const ts = (window as Window & { + tsjs: { + addAdUnits(unit: unknown): void; + requestAds(): void; + }; + }).tsjs; + ts.addAdUnits({ + code: "direct-api-slot", + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: "example", params: {} }], + }); + ts.requestAds(); + }); + + await expect + .poll(() => + page.evaluate(() => { + const result = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + renders: Array<{ + slotId: string; + source: string; + outcome: string; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export(); + return result.renders.find( + (render) => render.slotId === "direct-api-slot", + ); + }), + ) + .toMatchObject({ + slotId: "direct-api-slot", + source: "direct_auction", + outcome: "confirmed", + }); + }); + + test("actual generated Prebid selects the traced TS bid before a probable GAM result", async ({ + page, + }) => { + await openTesterPage(page); + await expect + .poll(() => + page.evaluate(() => { + const win = window as Window & { + pbjs?: { requestBids?: unknown }; + googletag?: { + pubads(): { __tsRefreshWrapped?: boolean }; + }; + }; + return ( + typeof win.pbjs?.requestBids === "function" && + win.googletag?.pubads().__tsRefreshWrapped === true + ); + }), + ) + .toBe(true); + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + latestSlot(): unknown; + setSuppressCreative(value: boolean): void; + }; + googletag: { pubads(): { refresh(slots: unknown[]): void } }; + }; + win.adTraceFixture.setSuppressCreative(true); + win.googletag.pubads().refresh([win.adTraceFixture.latestSlot()]); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { + stages?: Record< + string, + { outcome?: string; confidence?: string } + >; + } + | undefined; + return { + prebid: slot?.stages?.prebid, + gam: slot?.stages?.gam, + }; + }) + .toMatchObject({ + prebid: { outcome: "won", confidence: "definitive" }, + gam: { + outcome: "trusted_server_candidate", + confidence: "probable", + }, + }); + }); + + test("client selection, backfill, direct-or-unattributed, and retained generations stay independent", async ({ + page, + }) => { + await openTesterPage(page); + + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { simulateClientSelection(): void }; + }; + win.adTraceFixture.simulateClientSelection(); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { stages?: Record } + | undefined; + return { + prebid: slot?.stages?.prebid?.outcome, + gam: slot?.stages?.gam?.outcome, + }; + }) + .toEqual({ prebid: "lost", gam: "client_prebid_candidate" }); + + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + latestSlot(): unknown; + setNextRender(flags: { isBackfill: boolean }): void; + requestCurrent(): void; + }; + tsjs: { + captureAdTraceRequest(slot: unknown, trigger: string): void; + }; + }; + const slot = win.adTraceFixture.latestSlot(); + win.adTraceFixture.setNextRender({ isBackfill: true }); + win.tsjs.captureAdTraceRequest(slot, "fixture_backfill"); + win.adTraceFixture.requestCurrent(); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { stages?: Record } + | undefined; + return slot?.stages?.gam?.outcome; + }) + .toBe("backfill"); + + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + latestSlot(): { clearTargeting(): void }; + requestCurrent(): void; + }; + tsjs: { + captureAdTraceRequest(slot: unknown, trigger: string): void; + }; + }; + const slot = win.adTraceFixture.latestSlot(); + slot.clearTargeting(); + win.tsjs.captureAdTraceRequest(slot, "fixture_direct"); + win.adTraceFixture.requestCurrent(); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { stages?: Record } + | undefined; + return slot?.stages?.gam?.outcome; + }) + .toBe("direct_or_unattributed"); + + const generations = await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + simulateRetainedGenerationAcknowledgement(): unknown; + }; + }; + return win.adTraceFixture.simulateRetainedGenerationAcknowledgement(); + }); + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as { + latestGeneration: number; + generations: Array<{ + generation: number; + stages: { creative: { outcome: string } }; + }>; + }; + const retained = generations as { first: number; second: number }; + expect(slot.latestGeneration).toBe(retained.second); + expect( + slot.generations.find((item) => item.generation === retained.first) + ?.stages.creative.outcome, + ).toBe("load_acknowledged"); + expect( + slot.generations.find((item) => item.generation === retained.second) + ?.stages.creative.outcome, + ).not.toBe("load_acknowledged"); + }); +}); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts new file mode 100644 index 000000000..09ceb53d5 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; +import { runtimeUrl } from "../../helpers/state.js"; + +test("console query alone does not install ad trace when config is disabled", async ({ + page, +}) => { + await page.goto(runtimeUrl("/?ts_console=1"), { + waitUntil: "domcontentloaded", + }); + + await expect + .poll(() => + page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ) + .toBe("undefined"); +}); diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml new file mode 100644 index 000000000..851d50cdf --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml @@ -0,0 +1,67 @@ +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration-admin-password-32-bytes-ok" + +[publisher] +domain = "localhost" +cookie_domain = "localhost" +origin_url = "http://127.0.0.1:8888" +proxy_secret = "integration-test-proxy-secret" + +[ec] +passphrase = "integration-test-ec-secret-padded-32" +ec_store = "ec_identity_store" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.ad_trace] +enabled = true + +[integrations.prebid] +enabled = true +server_url = "http://127.0.0.1:8888/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +timeout_ms = 750 +bidders = ["example-bidder"] +client_side_bidders = [] +debug = false +test_mode = true + +[integrations.gpt] +enabled = true +script_url = "https://ads.example.com/gpt.js" +cache_ttl_seconds = 3600 +rewrite_script = false + +[proxy] +certificate_check = false +allowed_domains = ["assets.example.com"] + +[auction] +enabled = true +providers = ["prebid"] +timeout_ms = 1000 +allowed_context_keys = [] + +[creative_opportunities] +gam_network_id = "123456789" +auction_timeout_ms = 750 +price_granularity = "dense" + +[[creative_opportunities.slot]] +id = "ad-trace-slot" +div_id = "ad-trace-slot" +gam_unit_path = "/123456789/example/ad-trace" +page_patterns = ["/", "/spa*"] +formats = [{ width = 300, height = 250 }] + +[creative_opportunities.slot.providers.prebid] +bidders = { example-bidder = { placement = "example-placement" } } + +[debug] +ja4_endpoint_enabled = false +inject_adm_for_testing = true diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile new file mode 100644 index 000000000..7996d6fde --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile @@ -0,0 +1,15 @@ +# Deterministic publisher/PBS fixture for the tester-only ad trace journey. +FROM php:8.3-cli-alpine + +WORKDIR /var/www/html + +COPY crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/ /var/www/html/ +COPY target/integration-test-artifacts/prebid/ /opt/prebid/ + +RUN bundle="$(find /opt/prebid -maxdepth 1 -name 'trusted-prebid-*.js' -type f | head -n 1)" \ + && test -n "$bundle" \ + && cp "$bundle" /var/www/html/prebid-bundle.js + +EXPOSE 80 + +CMD ["php", "-S", "0.0.0.0:80", "router.php"] diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php new file mode 100644 index 000000000..e7bfca062 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php @@ -0,0 +1,201 @@ + + + + + + Trusted Server ad trace fixture + + + + +

Ad trace contract fixture

+

This page uses deterministic local PBS, GPT, and universal creative protocol mocks.

+
+ + diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php new file mode 100644 index 000000000..e14e7b6e8 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php @@ -0,0 +1,50 @@ + $imp) { + $slotId = is_string($imp['id'] ?? null) ? $imp['id'] : 'ad-trace-slot'; + $bids[] = [ + 'id' => 'example-bid-' . ($index + 1), + 'impid' => $slotId, + 'adid' => 'example-ad-' . ($index + 1), + 'price' => 1.25, + 'adm' => '
Example creative loaded
', + 'crid' => 'example-creative-' . ($index + 1), + 'w' => 300, + 'h' => 250, + 'adomain' => ['advertiser.example.com'], + ]; + } + + header('Content-Type: application/json'); + echo json_encode([ + 'id' => is_string($request['id'] ?? null) ? $request['id'] : 'example-auction', + 'seatbid' => $bids ? [['seat' => 'example-bidder', 'bid' => $bids]] : [], + 'cur' => 'USD', + ], JSON_UNESCAPED_SLASHES); + return; +} + +if ($path === '/prebid-bundle.js') { + header('Content-Type: application/javascript'); + readfile(__DIR__ . '/prebid-bundle.js'); + return; +} + +if ($path === '/' || $path === '/spa-one' || $path === '/spa-two') { + require __DIR__ . '/index.php'; + return; +} + +http_response_code(404); +header('Content-Type: text/plain'); +echo 'Not found'; diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index e85b1d8d1..e41d84dc9 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -44,6 +44,9 @@ fn test_settings() -> Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.ad_trace] + enabled = true "#, ) .expect("should parse parity test settings") @@ -85,6 +88,24 @@ async fn axum_get(uri: &str) -> (u16, HeaderMap) { (resp.status().as_u16(), resp.headers().clone()) } +async fn axum_document_get(uri: &str) -> (u16, HeaderMap) { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("GET") + .uri(uri) + .header("sec-fetch-dest", "document") + .body(AxumBody::empty()) + .expect("should build document GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + /// Send a POST request to the Axum adapter and return (status, headers, body bytes). async fn axum_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { use http_body_util::BodyExt as _; @@ -131,6 +152,17 @@ async fn cf_get(uri: &str) -> (u16, HeaderMap) { (resp.status().as_u16(), resp.headers().clone()) } +async fn cf_document_get(uri: &str) -> (u16, HeaderMap) { + let req = request_builder() + .method("GET") + .uri(uri) + .header("sec-fetch-dest", "document") + .body(edgezero_core::body::Body::empty()) + .expect("should build document GET request"); + let resp = cf_router().oneshot(req).await.expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + /// Send a POST request to the Cloudflare adapter and return (status, headers, body bytes). async fn cf_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { let router = cf_router(); @@ -174,6 +206,17 @@ async fn spin_get(uri: &str) -> (u16, HeaderMap) { (s, h) } +async fn spin_document_get(uri: &str) -> (u16, HeaderMap) { + let req = request_builder() + .method("GET") + .uri(uri) + .header("sec-fetch-dest", "document") + .body(edgezero_core::body::Body::empty()) + .expect("should build document GET request"); + let resp = spin_router().oneshot(req).await.expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + /// Send a POST request to the Spin adapter and return (status, headers, body bytes). async fn spin_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { let router = spin_router(); @@ -456,6 +499,34 @@ async fn verify_signature_route_parity() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn console_activation_finalizes_auth_short_circuits() { + let uri = "/_ts/admin/keys/rotate?ts_console=1"; + let responses = [ + axum_document_get(uri).await, + cf_document_get(uri).await, + spin_document_get(uri).await, + ]; + + for (status, headers) in responses { + assert_eq!(status, 401); + assert_eq!( + headers + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("private, no-store") + ); + assert!( + headers + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| value.starts_with("__Host-ts-console=1;")), + "auth short-circuit should preserve the console session action" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn admin_rotate_unauthenticated_parity() { // Both adapters must return 401 for unauthenticated admin requests on the diff --git a/crates/trusted-server-js/lib/src/core/ad_trace.ts b/crates/trusted-server-js/lib/src/core/ad_trace.ts new file mode 100644 index 000000000..aca037f5d --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/ad_trace.ts @@ -0,0 +1,505 @@ +import type { + AdTraceApi, + AdTraceConfidence, + AdTraceEvent, + AdTraceEventKind, + AdTraceExport, + AdTraceObservation, + AdTraceStage, + AdTraceStageName, + GenerationTraceSnapshot, + RenderTraceOutcome, + RenderTraceSnapshot, + RenderTraceVisibility, + SlotTraceSnapshot, +} from './types'; + +export const AD_TRACE_MAX_EVENTS = 256; +export const AD_TRACE_MAX_SLOTS = 64; +export const AD_TRACE_MAX_GENERATIONS = 8; +export const AD_TRACE_MAX_RENDERS = 200; +export const AD_TRACE_ACK_TTL_MS = 30_000; +const AD_TRACE_MAX_LISTENERS = 32; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const LABEL_RE = /^[\w.-]{1,64}$/; +const EVENT_KINDS = new Set([ + 'ts_auction_observed', + 'ts_winner_observed', + 'prebid_auction_init', + 'prebid_bid_response', + 'prebid_targeting_selected', + 'prebid_bid_won', + 'prebid_auction_end', + 'prebid_render_succeeded', + 'prebid_render_failed', + 'gpt_targeting_applied', + 'gpt_request_started', + 'gpt_slot_requested', + 'gpt_slot_response_received', + 'gpt_slot_render_ended', + 'gpt_slot_onload', + 'aps_display_bids_set', + 'pb_render_requested', + 'pb_render_rejected', + 'pb_render_served', + 'direct_render_rejected', + 'creative_load_acknowledged', + 'generation_superseded', +]); +const CONFIDENCES = new Set(['definitive', 'strong', 'probable', 'none']); +const EMPTY_STAGE: AdTraceStage = { outcome: 'not_observed', confidence: 'none', reason: 'none' }; + +type MutableGeneration = GenerationTraceSnapshot; +interface MutableSlot { + slotId: string; + latestGeneration: number; + baseStages: Record; + generations: MutableGeneration[]; +} + +export interface AdTraceStore extends AdTraceApi { + record(observation: AdTraceObservation): void; + nextGeneration(slotId: string): number; + subscribe(listener: () => void): () => void; + bindElement(slotId: string, generation: number, element: HTMLElement): void; + getBoundElement(slotId: string, generation: number): HTMLElement | undefined; + updateVisibility(slotId: string, generation: number, visibility: RenderTraceVisibility): void; +} + +function stages(): Record { + return { + trustedServer: { ...EMPTY_STAGE }, + prebid: { ...EMPTY_STAGE }, + gam: { ...EMPTY_STAGE }, + creative: { ...EMPTY_STAGE }, + }; +} + +function safeLabel(value: unknown): string | undefined { + return typeof value === 'string' && LABEL_RE.test(value) ? value : undefined; +} +function safeUuid(value: unknown): string | undefined { + return typeof value === 'string' && UUID_RE.test(value) ? value : undefined; +} +function cloneStages(value: Record) { + return Object.fromEntries( + Object.entries(value).map(([key, stage]) => [key, { ...stage }]) + ) as Record; +} +function cloneFreeze(value: T): T { + const clone = JSON.parse(JSON.stringify(value)) as T; + const freeze = (item: unknown): void => { + if (!item || typeof item !== 'object' || Object.isFrozen(item)) return; + Object.freeze(item); + Object.values(item as Record).forEach(freeze); + }; + freeze(clone); + return clone; +} +function newSlot(slotId: string): MutableSlot { + return { slotId, latestGeneration: 0, baseStages: stages(), generations: [] }; +} + +function updateStage(target: Record, event: AdTraceEvent): void { + const explicit = event.outcome + ? { + outcome: event.outcome, + confidence: event.confidence ?? 'none', + reason: event.reason ?? 'observed', + } + : undefined; + switch (event.kind) { + case 'ts_winner_observed': + target.trustedServer = { + outcome: 'won', + confidence: 'definitive', + reason: 'final_server_winner', + }; + break; + case 'ts_auction_observed': + target.trustedServer = explicit ?? { + outcome: 'unresolved', + confidence: 'none', + reason: 'terminal_summary', + }; + break; + case 'prebid_targeting_selected': + target.prebid = explicit ?? { + outcome: event.bidTraceId ? 'won' : 'client_bid_won', + confidence: 'definitive', + reason: 'selected_targeting', + }; + break; + case 'prebid_auction_end': + if (explicit && target.prebid.confidence !== 'definitive') target.prebid = explicit; + break; + case 'prebid_bid_won': + if (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') { + target.prebid = { + ...target.prebid, + reason: 'selected_targeting_with_bid_won', + }; + if (target.gam.outcome === 'direct_or_unattributed') { + target.gam = { + outcome: 'client_prebid_candidate', + confidence: 'probable', + reason: 'client_bid_won_and_gpt_rendered', + }; + } + } + break; + case 'prebid_render_succeeded': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'prebid_render_succeeded', + confidence: 'strong', + reason: event.reason ?? 'prebid_render_succeeded', + }; + } + break; + case 'prebid_render_failed': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'render_failed', + confidence: 'definitive', + reason: event.reason ?? 'prebid_render_failed', + }; + } + break; + case 'gpt_slot_render_ended': + // Cooperative acknowledgement is stronger than later GPT callbacks and + // must never be downgraded to a probable candidate. + if (target.gam.confidence === 'definitive') break; + if (explicit?.outcome === 'unresolved') target.gam = explicit; + else if (event.isEmpty) + target.gam = { outcome: 'empty', confidence: 'definitive', reason: 'gpt_empty' }; + else if (event.isBackfill) + target.gam = { outcome: 'backfill', confidence: 'definitive', reason: 'gpt_backfill' }; + else if (event.bidTraceId) + target.gam = { + outcome: 'trusted_server_candidate', + confidence: 'probable', + reason: 'trace_targeting_rendered', + }; + else if ( + (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') && + target.prebid.reason === 'selected_targeting_with_bid_won' + ) + target.gam = { + outcome: 'client_prebid_candidate', + confidence: 'probable', + reason: 'client_bid_won_and_gpt_rendered', + }; + else + target.gam = { + outcome: 'direct_or_unattributed', + confidence: 'probable', + reason: 'non_empty_unattributed', + }; + break; + case 'aps_display_bids_set': + // APS setting display bids is a handoff only. GAM attribution remains + // unobserved until a correlated non-empty GPT render arrives. + break; + case 'gpt_slot_onload': + if (target.creative.outcome === 'not_observed') + target.creative = { + outcome: 'gpt_iframe_onload', + confidence: 'probable', + reason: 'gpt_slot_onload', + }; + break; + case 'pb_render_served': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'renderer_served', + confidence: 'strong', + reason: event.reason ?? 'pb_render_response', + }; + } + break; + case 'direct_render_rejected': + if (target.creative.confidence === 'none') { + target.creative = { + outcome: 'rejected', + confidence: 'none', + reason: event.reason ?? 'direct_render_rejected', + }; + } + break; + case 'creative_load_acknowledged': + target.creative = { + outcome: 'load_acknowledged', + confidence: 'definitive', + reason: 'source_validated_load', + }; + if (event.reason !== 'direct_iframe_load') { + target.gam = { + outcome: 'trusted_server_won', + confidence: 'definitive', + reason: 'creative_load_acknowledged', + }; + } + break; + case 'generation_superseded': + // Ownership cleanup is lifecycle evidence, not contradictory render + // evidence. Preserve every previously observed stage unchanged. + break; + default: + break; + } +} + +function snapshot(slot: MutableSlot): SlotTraceSnapshot { + const latest = slot.generations.at(-1); + return { + slotId: slot.slotId, + latestGeneration: slot.latestGeneration, + generations: slot.generations.map((item) => ({ + generation: item.generation, + stages: cloneStages(item.stages), + })), + stages: cloneStages(latest?.stages ?? slot.baseStages), + }; +} + +function isRenderEvent(kind: AdTraceEventKind): boolean { + return ( + kind === 'gpt_request_started' || + kind === 'gpt_slot_render_ended' || + kind === 'prebid_render_succeeded' || + kind === 'prebid_render_failed' || + kind === 'pb_render_requested' || + kind === 'pb_render_rejected' || + kind === 'pb_render_served' || + kind === 'direct_render_rejected' || + kind === 'creative_load_acknowledged' || + kind === 'generation_superseded' + ); +} + +function renderSource(event: AdTraceEvent): RenderTraceSnapshot['source'] { + if (event.reason?.startsWith('direct_')) return 'direct_auction'; + if (event.kind.startsWith('pb_render_') || event.kind === 'creative_load_acknowledged') + return 'pb_render'; + return 'gpt'; +} + +function renderOutcome( + current: Pick, + event: AdTraceEvent +): { outcome: RenderTraceOutcome; confidence: AdTraceConfidence } { + if (current.outcome === 'confirmed') return { outcome: 'confirmed', confidence: 'definitive' }; + if (current.outcome === 'empty' && current.confidence === 'definitive') { + return { outcome: 'empty', confidence: 'definitive' }; + } + if (event.kind === 'creative_load_acknowledged') + return { outcome: 'confirmed', confidence: 'definitive' }; + if (current.outcome === 'served') return { outcome: 'served', confidence: 'strong' }; + if (event.kind === 'pb_render_served') return { outcome: 'served', confidence: 'strong' }; + if (event.kind === 'gpt_slot_render_ended') + return event.isEmpty + ? { outcome: 'empty', confidence: 'definitive' } + : { outcome: 'gam_only', confidence: 'probable' }; + if (current.outcome === 'gam_only') return { outcome: 'gam_only', confidence: 'probable' }; + return { outcome: 'unresolved', confidence: 'none' }; +} + +export function createAdTraceStore( + now: () => number = () => (typeof performance === 'undefined' ? Date.now() : performance.now()) +): AdTraceStore { + const slots = new Map(); + const events: AdTraceEvent[] = []; + const renders: RenderTraceSnapshot[] = []; + const renderByGeneration = new Map(); + const elementByGeneration = new Map(); + const listeners = new Set<() => void>(); + let sequence = 0; + let generationSequence = 0; + let renderSequence = 0; + let droppedEvents = 0; + let evictedSlots = 0; + const ensureSlot = (slotId: string): MutableSlot => { + let slot = slots.get(slotId); + if (slot) return slot; + if (slots.size >= AD_TRACE_MAX_SLOTS) { + const oldest = slots.keys().next().value as string | undefined; + if (oldest) { + slots.delete(oldest); + evictedSlots += 1; + } + } + slot = newSlot(slotId); + slots.set(slotId, slot); + return slot; + }; + const notify = (): void => listeners.forEach((listener) => listener()); + const emitRender = (render: RenderTraceSnapshot): void => { + if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return; + window.dispatchEvent(new CustomEvent('tsjs:adRendered', { detail: cloneFreeze(render) })); + }; + const updateRender = (event: AdTraceEvent, slotId: string, generation: number): void => { + if (!isRenderEvent(event.kind)) return; + const key = `${slotId}:${generation}`; + let render = renderByGeneration.get(key); + const timestamp = now(); + if (!render) { + render = { + sequence: ++renderSequence, + slotId, + generation, + source: renderSource(event), + outcome: 'unresolved', + confidence: 'none', + visibility: 'unknown', + createdAt: timestamp, + updatedAt: timestamp, + }; + renderByGeneration.set(key, render); + renders.push(render); + if (renders.length > AD_TRACE_MAX_RENDERS) { + const evicted = renders.shift(); + if (evicted) { + const evictedKey = `${evicted.slotId}:${evicted.generation}`; + renderByGeneration.delete(evictedKey); + elementByGeneration.delete(evictedKey); + } + } + } + const next = renderOutcome(render, event); + render.outcome = next.outcome; + render.confidence = next.confidence; + if (event.reason?.startsWith('direct_')) render.source = 'direct_auction'; + else if (event.kind.startsWith('pb_render_') || event.kind === 'creative_load_acknowledged') + render.source = render.source === 'direct_auction' ? render.source : 'pb_render'; + if (event.auctionTraceId) render.auctionTraceId = event.auctionTraceId; + if (event.bidTraceId) render.bidTraceId = event.bidTraceId; + render.updatedAt = timestamp; + emitRender(render); + }; + + return { + record(observation) { + if (!EVENT_KINDS.has(observation.kind)) return; + if (observation.confidence && !CONFIDENCES.has(observation.confidence)) return; + const slotId = safeLabel(observation.slotId); + const generation = + Number.isInteger(observation.generation) && (observation.generation ?? 0) > 0 + ? observation.generation + : undefined; + const event: AdTraceEvent = { + sequence: ++sequence, + timestamp: now(), + kind: observation.kind, + ...(slotId ? { slotId } : {}), + ...(generation ? { generation } : {}), + ...(safeUuid(observation.auctionTraceId) + ? { auctionTraceId: observation.auctionTraceId } + : {}), + ...(safeUuid(observation.bidTraceId) ? { bidTraceId: observation.bidTraceId } : {}), + ...(safeLabel(observation.provider) ? { provider: observation.provider } : {}), + ...(safeLabel(observation.bidder) ? { bidder: observation.bidder } : {}), + ...(safeLabel(observation.outcome) ? { outcome: observation.outcome } : {}), + ...(observation.confidence ? { confidence: observation.confidence } : {}), + ...(safeLabel(observation.reason) ? { reason: observation.reason } : {}), + ...(typeof observation.isEmpty === 'boolean' ? { isEmpty: observation.isEmpty } : {}), + ...(typeof observation.isBackfill === 'boolean' + ? { isBackfill: observation.isBackfill } + : {}), + }; + events.push(event); + if (events.length > AD_TRACE_MAX_EVENTS) { + events.shift(); + droppedEvents += 1; + } + if (slotId) { + const slot = ensureSlot(slotId); + const exact = generation + ? slot.generations.find((item) => item.generation === generation) + : undefined; + if (exact) updateStage(exact.stages, event); + else if ( + !generation && + (event.kind === 'ts_winner_observed' || event.kind === 'ts_auction_observed') + ) { + // Generationless server evidence seeds only the next request. Updating + // the latest retained generation would rewrite prior-navigation history. + updateStage(slot.baseStages, event); + } + if (generation) updateRender(event, slotId, generation); + } + notify(); + }, + nextGeneration(slotId) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId) return 0; + const slot = ensureSlot(safeSlotId); + slot.latestGeneration = ++generationSequence; + slot.generations.push({ + generation: slot.latestGeneration, + stages: cloneStages(slot.baseStages), + }); + if (slot.generations.length > AD_TRACE_MAX_GENERATIONS) slot.generations.shift(); + notify(); + return slot.latestGeneration; + }, + getSlot(slotId) { + const slot = slots.get(slotId); + return slot ? cloneFreeze(snapshot(slot)) : undefined; + }, + getEvents() { + return cloneFreeze(events); + }, + getRenderTimeline() { + return cloneFreeze(renders); + }, + export() { + const value: AdTraceExport = { + version: 1, + slots: [...slots.values()].map(snapshot), + events, + renders, + metadata: { droppedEvents, evictedSlots }, + }; + return cloneFreeze(value); + }, + subscribe(listener) { + if (listeners.size >= AD_TRACE_MAX_LISTENERS) return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + bindElement(slotId, generation, element) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId || !Number.isInteger(generation) || generation <= 0) return; + const key = `${safeSlotId}:${generation}`; + if (!elementByGeneration.has(key) && elementByGeneration.size >= AD_TRACE_MAX_RENDERS) { + const oldest = elementByGeneration.keys().next().value as string | undefined; + if (oldest) elementByGeneration.delete(oldest); + } + elementByGeneration.set(key, element); + }, + getBoundElement(slotId, generation) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId || !Number.isInteger(generation) || generation <= 0) return undefined; + return elementByGeneration.get(`${safeSlotId}:${generation}`); + }, + updateVisibility(slotId, generation, visibility) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId || !Number.isInteger(generation) || generation <= 0) return; + const render = renderByGeneration.get(`${safeSlotId}:${generation}`); + if (!render || render.visibility === visibility) return; + render.visibility = visibility; + render.updatedAt = now(); + emitRender(render); + notify(); + }, + }; +} + +export function isCanonicalTraceUuid(value: unknown): value is string { + return safeUuid(value) !== undefined; +} +export function isBoundedTraceLabel(value: unknown): value is string { + return safeLabel(value) !== undefined; +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 40f54367a..c9b94b7cd 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -3,6 +3,12 @@ // and the Prebid.js trustedServer adapter. import { log } from './log'; +import type { + AuctionTraceOutcome, + AuctionTraceSource, + AuctionTraceSummary, + TrustedServerBidTrace, +} from './types'; // --------------------------------------------------------------------------- // Types @@ -38,6 +44,11 @@ export interface AdRequest { } /** A parsed bid from an OpenRTB seatbid response. */ +export type AuctionClientResult = + | { kind: 'ok'; summary?: AuctionTraceSummary; bids: AuctionBid[] } + | { kind: 'transport_error'; reason: 'network' | 'http' } + | { kind: 'invalid_response'; reason: 'non_json' | 'invalid_shape' }; + export interface AuctionBid { /** Matches the `impid` in the response — corresponds to adUnit `code`. */ impid: string; @@ -55,6 +66,72 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; + /** Tester-gated trace joined to the validated root summary. */ + trace?: TrustedServerBidTrace; +} + +const TRACE_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const TRACE_LABEL_RE = /^[\w.-]{1,64}$/; +const TRACE_SOURCES = new Set([ + 'initial_navigation', + 'spa_navigation', + 'auction_api', +]); +const TRACE_OUTCOMES = new Set([ + 'completed', + 'no_bid', + 'skipped', + 'failed', + 'abandoned', +]); + +/** Strictly parse the optional Trusted Server root extension. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseAuctionTraceSummary(body: any): AuctionTraceSummary | undefined { + const trace = body?.ext?.trusted_server?.trace; + if ( + trace?.version !== 1 || + !TRACE_UUID_RE.test(trace.auction_trace_id) || + !TRACE_SOURCES.has(trace.source) || + !TRACE_OUTCOMES.has(trace.outcome) + ) { + return undefined; + } + return { + version: 1, + auctionTraceId: trace.auction_trace_id, + source: trace.source, + outcome: trace.outcome, + }; +} + +function parseBidTrace( + bid: any, // eslint-disable-line @typescript-eslint/no-explicit-any + root: AuctionTraceSummary | undefined +): TrustedServerBidTrace | undefined { + const trace = bid?.ext?.trusted_server?.trace; + if ( + !root || + root.outcome !== 'completed' || + trace?.version !== 1 || + !TRACE_UUID_RE.test(trace.bid_trace_id) || + typeof trace.slot_id !== 'string' || + trace.slot_id !== bid?.impid || + !TRACE_LABEL_RE.test(trace.slot_id) || + !TRACE_LABEL_RE.test(trace.provider) || + !TRACE_LABEL_RE.test(trace.bidder) + ) { + return undefined; + } + return { + version: 1, + auctionTraceId: root.auctionTraceId, + bidTraceId: trace.bid_trace_id, + source: root.source, + slotId: trace.slot_id, + provider: trace.provider, + bidder: trace.bidder, + }; } // --------------------------------------------------------------------------- @@ -123,6 +200,7 @@ export function buildAdRequest(units: any[], options?: { eids?: AuctionEid[] }): // eslint-disable-next-line @typescript-eslint/no-explicit-any export function parseAuctionResponse(body: any): AuctionBid[] { const bids: AuctionBid[] = []; + const rootTrace = parseAuctionTraceSummary(body); const seatbids = body?.seatbid; if (!Array.isArray(seatbids)) return bids; @@ -137,6 +215,7 @@ export function parseAuctionResponse(body: any): AuctionBid[] { // `if (!bid.adm)` guard. The client-side `typeof !== 'string'` check in // sanitizeCreativeHtml is a second line of defense for callers that bypass // parseAuctionResponse and pass untrusted values directly. + const trace = parseBidTrace(b, rootTrace); bids.push({ impid: b.impid ?? '', adm: b.adm ?? '', @@ -146,25 +225,45 @@ export function parseAuctionResponse(body: any): AuctionBid[] { seat, creativeId: b.crid ?? `${seat}-${b.impid ?? ''}`, adomain: Array.isArray(b.adomain) ? b.adomain : [], + ...(trace ? { trace } : {}), }); } } return bids; } +function isValidAuctionResponseShape(data: Record): boolean { + const seatbid = data.seatbid; + // Preserve the legacy valid empty response while rejecting a present but + // malformed collection that would otherwise be misreported as no-bid. + if (seatbid === undefined) return true; + if (!Array.isArray(seatbid)) return false; + return seatbid.every((seat) => { + if (!seat || typeof seat !== 'object' || Array.isArray(seat)) return false; + const bids = (seat as Record).bid; + return ( + bids === undefined || + (Array.isArray(bids) && + bids.every((bid) => !!bid && typeof bid === 'object' && !Array.isArray(bid))) + ); + }); +} + // --------------------------------------------------------------------------- // Auction HTTP call // --------------------------------------------------------------------------- /** - * POST an {@link AdRequest} to the given endpoint and return parsed bids. - * - * Returns an empty array on network or parse errors (non-throwing). + * POST an {@link AdRequest} and distinguish a valid empty auction from + * transport or response-shape failures. */ -export async function sendAuction(endpoint: string, request: AdRequest): Promise { +export async function sendAuction( + endpoint: string, + request: AdRequest +): Promise { if (typeof fetch !== 'function') { log.warn('auction: fetch not available'); - return []; + return { kind: 'transport_error', reason: 'network' }; } log.info('auction: sending request', { endpoint, units: request.adUnits.length }); @@ -179,17 +278,36 @@ export async function sendAuction(endpoint: string, request: AdRequest): Promise }); const ct = res.headers.get('content-type') || ''; - if (res.ok && ct.includes('application/json')) { - const data: unknown = await res.json(); - const bids = parseAuctionResponse(data); - log.info('auction: received bids', { count: bids.length }); - return bids; + if (!res.ok) { + log.warn('auction: unexpected response', { ok: res.ok, status: res.status, ct }); + return { kind: 'transport_error', reason: 'http' }; + } + if (!ct.includes('application/json')) { + log.warn('auction: non-json response', { status: res.status, ct }); + return { kind: 'invalid_response', reason: 'non_json' }; } - log.warn('auction: unexpected response', { ok: res.ok, status: res.status, ct }); - return []; + let data: unknown; + try { + data = await res.json(); + } catch (err) { + log.warn('auction: invalid json response', err); + return { kind: 'invalid_response', reason: 'non_json' }; + } + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + !isValidAuctionResponseShape(data as Record) + ) { + return { kind: 'invalid_response', reason: 'invalid_shape' }; + } + const bids = parseAuctionResponse(data); + const summary = parseAuctionTraceSummary(data); + log.info('auction: received bids', { count: bids.length }); + return { kind: 'ok', ...(summary ? { summary } : {}), bids }; } catch (err) { log.warn('auction: request failed', err); - return []; + return { kind: 'transport_error', reason: 'network' }; } } diff --git a/crates/trusted-server-js/lib/src/core/global.d.ts b/crates/trusted-server-js/lib/src/core/global.d.ts index c7c8b08fb..2e753c6d9 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -2,6 +2,8 @@ import type { TsjsApi } from './types'; declare global { interface Window { + /** Request-scoped server bootstrap consumed synchronously by ad trace. */ + __tsjs_adTraceActive?: boolean; tsjs?: TsjsApi; pbjs?: TsjsApi; } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index e39300a14..40b41d524 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -4,6 +4,7 @@ import { collectContext } from './context'; import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { buildAdRequest, sendAuction } from './auction'; +import type { AuctionTraceSummary, TrustedServerBidTrace } from './types'; export type RequestAdsCallback = () => void; export interface RequestAdsOptions { @@ -11,6 +12,16 @@ export interface RequestAdsOptions { timeout?: number; } +const MAX_DIRECT_RENDER_OWNERS = 64; + +interface DirectRenderOwner { + token: symbol; + slotId: string; + generation?: number; +} + +const latestDirectOwners = new Map(); + type RenderCreativeInlineOptions = { slotId: string; // Accept unknown input here because bidder JSON is untrusted at runtime. @@ -19,8 +30,64 @@ type RenderCreativeInlineOptions = { creativeHeight?: number; seat: string; creativeId: string; + owner: DirectRenderOwner; + trace?: TrustedServerBidTrace; }; +function claimDirectOwner(slotId: string): DirectRenderOwner { + const previous = latestDirectOwners.get(slotId); + if (previous) recordDirectRejection(previous, 'direct_owner_replaced'); + const ts = window.tsjs; + const generation = ts?.recordAdTrace ? ts.nextAdTraceGeneration?.(slotId) : undefined; + const owner: DirectRenderOwner = { + token: Symbol(slotId), + slotId, + ...(generation && generation > 0 ? { generation } : {}), + }; + latestDirectOwners.delete(slotId); + latestDirectOwners.set(slotId, owner); + if (latestDirectOwners.size > MAX_DIRECT_RENDER_OWNERS) { + const oldest = latestDirectOwners.keys().next().value as string | undefined; + if (oldest) { + const evicted = latestDirectOwners.get(oldest); + if (evicted) recordDirectRejection(evicted, 'direct_owner_evicted'); + latestDirectOwners.delete(oldest); + } + } + return owner; +} + +function ownerIsCurrent(owner: DirectRenderOwner): boolean { + return latestDirectOwners.get(owner.slotId) === owner; +} + +function recordRootSummary( + summary: AuctionTraceSummary | undefined, + owner: DirectRenderOwner, + hasWinner: boolean +): void { + if (!summary || !owner.generation) return; + window.tsjs?.recordAdTrace?.({ + kind: 'ts_auction_observed', + slotId: owner.slotId, + generation: owner.generation, + auctionTraceId: summary.auctionTraceId, + outcome: summary.outcome === 'completed' && !hasWinner ? 'no_bid' : summary.outcome, + confidence: 'definitive', + reason: 'terminal_summary', + }); +} + +function recordDirectRejection(owner: DirectRenderOwner, reason: string): void { + if (!owner.generation) return; + window.tsjs?.recordAdTrace?.({ + kind: 'direct_render_rejected', + slotId: owner.slotId, + generation: owner.generation, + reason, + }); +} + // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, @@ -39,34 +106,83 @@ export function requestAds( log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); try { const adUnits = getAllUnits(); + const requestedSlotIds = [ + ...new Set( + adUnits + .map((unit) => unit.code) + .filter((code): code is string => typeof code === 'string' && code.length > 0) + ), + ]; + const owners = new Map(requestedSlotIds.map((slotId) => [slotId, claimDirectOwner(slotId)])); const config = collectContext(); const payload = { ...buildAdRequest(adUnits), config }; log.debug('requestAds: payload', { units: adUnits.length, contextKeys: Object.keys(config) }); - // Use unified auction endpoint - void sendAuction('/auction', payload) - .then((bids) => { - log.info('requestAds: got bids', { count: bids.length }); - for (const bid of bids) { - if (!bid.impid) continue; - if (!bid.adm) { - log.debug('requestAds: bid has no adm, skipping', { slotId: bid.impid }); - continue; + void sendAuction('/auction', payload).then((result) => { + if (result.kind !== 'ok') { + for (const owner of owners.values()) { + if (ownerIsCurrent(owner)) { + recordDirectRejection(owner, `${result.kind}_${result.reason}`); } - renderCreativeInline({ - slotId: bid.impid, - creativeHtml: bid.adm, - creativeWidth: bid.width, - creativeHeight: bid.height, - seat: bid.seat, - creativeId: bid.creativeId, + } + return; + } + + log.info('requestAds: got bids', { count: result.bids.length }); + const bySlot = new Map(); + for (const bid of result.bids) { + if (!owners.has(bid.impid)) continue; + const existing = bySlot.get(bid.impid) ?? []; + existing.push(bid); + bySlot.set(bid.impid, existing); + } + + for (const [slotId, owner] of owners) { + if (!ownerIsCurrent(owner)) continue; + const slotBids = bySlot.get(slotId) ?? []; + recordRootSummary(result.summary, owner, slotBids.length > 0); + if (slotBids.length === 0) continue; + if (slotBids.length !== 1) { + recordDirectRejection(owner, 'ambiguous_winner'); + continue; + } + + const bid = slotBids[0]; + const trace = + bid.trace && + result.summary && + bid.trace.slotId === slotId && + bid.trace.auctionTraceId === result.summary.auctionTraceId + ? bid.trace + : undefined; + if (trace && owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'ts_winner_observed', + slotId, + generation: owner.generation, + auctionTraceId: trace.auctionTraceId, + bidTraceId: trace.bidTraceId, + provider: trace.provider, + bidder: trace.bidder, }); } - log.info('requestAds: rendered creatives from response'); - }) - .catch((err) => { - log.warn('requestAds: auction failed', err); - }); + if (!bid.adm) { + recordDirectRejection(owner, 'missing_adm'); + continue; + } + renderCreativeInline({ + slotId, + creativeHtml: bid.adm, + creativeWidth: bid.width, + creativeHeight: bid.height, + seat: bid.seat, + creativeId: bid.creativeId, + owner, + ...(trace ? { trace } : {}), + }); + } + log.info('requestAds: rendered creatives from response'); + }); // Synchronously invoke callback to match test expectations try { @@ -87,16 +203,24 @@ function renderCreativeInline({ creativeHeight, seat, creativeId, + owner, + trace, }: RenderCreativeInlineOptions): void { + if (!ownerIsCurrent(owner)) return; const container = findSlot(slotId) as HTMLElement | null; if (!container) { + recordDirectRejection(owner, 'slot_missing'); log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); return; } try { + if (owner.generation) { + window.tsjs?.bindAdTraceElement?.(slotId, owner.generation, container); + } const sanitization = sanitizeCreativeHtml(creativeHtml); if (sanitization.kind === 'rejected') { + recordDirectRejection(owner, 'creative_rejected'); log.warn('renderCreativeInline: rejected creative', { slotId, seat, @@ -107,6 +231,7 @@ function renderCreativeInline({ return; } + if (!ownerIsCurrent(owner)) return; // Clear the slot only after sanitization succeeds so rejected creatives never blank existing content. container.innerHTML = ''; @@ -132,8 +257,36 @@ function renderCreativeInline({ width, height, }); + iframe.addEventListener( + 'load', + () => { + if (!ownerIsCurrent(owner) || !iframe.isConnected || iframe.parentElement !== container) + return; + if (owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'creative_load_acknowledged', + slotId, + generation: owner.generation, + auctionTraceId: trace?.auctionTraceId, + bidTraceId: trace?.bidTraceId, + reason: 'direct_iframe_load', + }); + } + }, + { once: true } + ); iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); + if (owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: owner.generation, + auctionTraceId: trace?.auctionTraceId, + bidTraceId: trace?.bidTraceId, + reason: 'direct_iframe_created', + }); + } log.info('renderCreativeInline: rendered', { slotId, @@ -144,6 +297,7 @@ function renderCreativeInline({ originalLength: sanitization.originalLength, }); } catch (err) { + recordDirectRejection(owner, 'render_failed'); log.warn('renderCreativeInline: failed', { slotId, seat, creativeId, err }); } } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 360e2aa49..615f174ef 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -48,6 +48,126 @@ export interface AuctionDebugBidData { metadata?: Record; } +export type AuctionTraceSource = 'initial_navigation' | 'spa_navigation' | 'auction_api'; +export type AuctionTraceOutcome = 'completed' | 'no_bid' | 'skipped' | 'failed' | 'abandoned'; + +/** Privacy-safe summary emitted only for configured tester traffic. */ +export interface AuctionTraceSummary { + version: 1; + auctionTraceId: string; + source: AuctionTraceSource; + outcome: AuctionTraceOutcome; +} + +/** Privacy-safe trace for one final Trusted Server winning bid. */ +export interface TrustedServerBidTrace { + version: 1; + auctionTraceId: string; + bidTraceId: string; + source: AuctionTraceSource; + slotId: string; + provider: string; + bidder: string; +} + +export type AdTraceConfidence = 'definitive' | 'strong' | 'probable' | 'none'; +export type AdTraceStageName = 'trustedServer' | 'prebid' | 'gam' | 'creative'; +export interface AdTraceStage { + outcome: string; + confidence: AdTraceConfidence; + reason: string; +} + +export type AdTraceEventKind = + | 'ts_auction_observed' + | 'ts_winner_observed' + | 'prebid_auction_init' + | 'prebid_bid_response' + | 'prebid_targeting_selected' + | 'prebid_bid_won' + | 'prebid_auction_end' + | 'prebid_render_succeeded' + | 'prebid_render_failed' + | 'gpt_targeting_applied' + | 'gpt_request_started' + | 'gpt_slot_requested' + | 'gpt_slot_response_received' + | 'gpt_slot_render_ended' + | 'gpt_slot_onload' + | 'aps_display_bids_set' + | 'pb_render_requested' + | 'pb_render_rejected' + | 'pb_render_served' + | 'direct_render_rejected' + | 'creative_load_acknowledged' + | 'generation_superseded'; + +/** Sanitized observation accepted by the optional recorder. */ +export interface AdTraceObservation { + kind: AdTraceEventKind; + slotId?: string; + generation?: number; + auctionTraceId?: string; + bidTraceId?: string; + provider?: string; + bidder?: string; + outcome?: string; + confidence?: AdTraceConfidence; + reason?: string; + isEmpty?: boolean; + isBackfill?: boolean; +} + +export interface AdTraceEvent extends AdTraceObservation { + sequence: number; + timestamp: number; +} + +export interface GenerationTraceSnapshot { + generation: number; + stages: Record; +} + +export interface SlotTraceSnapshot { + slotId: string; + latestGeneration: number; + generations: GenerationTraceSnapshot[]; + /** Convenience view of only the latest retained generation. */ + stages: Record; +} + +export type RenderTraceOutcome = 'confirmed' | 'served' | 'gam_only' | 'empty' | 'unresolved'; +export type RenderTraceVisibility = 'visible' | 'hidden' | 'disconnected' | 'unknown'; + +export interface RenderTraceSnapshot { + sequence: number; + slotId: string; + generation: number; + auctionTraceId?: string; + bidTraceId?: string; + source: 'gpt' | 'pb_render' | 'direct_auction'; + outcome: RenderTraceOutcome; + confidence: AdTraceConfidence; + visibility: RenderTraceVisibility; + createdAt: number; + updatedAt: number; +} + +export interface AdTraceExport { + version: 1; + slots: SlotTraceSnapshot[]; + events: AdTraceEvent[]; + renders: RenderTraceSnapshot[]; + metadata: { droppedEvents: number; evictedSlots: number }; +} + +export interface AdTraceApi { + getSlot(slotId: string): SlotTraceSnapshot | undefined; + getEvents(): readonly AdTraceEvent[]; + getRenderTimeline(): readonly RenderTraceSnapshot[]; + export(): AdTraceExport; +} + /** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ export interface AuctionBidData { hb_pb?: string; @@ -57,6 +177,8 @@ export interface AuctionBidData { hb_cache_path?: string; nurl?: string; burl?: string; + /** Tester-gated trace; absent for ordinary traffic and malformed input. */ + trace?: TrustedServerBidTrace; /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ adm?: string; /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ @@ -90,6 +212,80 @@ export interface TsjsApi { adSlots?: AuctionSlot[]; /** Winning bid targeting data injected before . */ bids?: Record; + /** Tester-gated terminal auction summary. */ + auctionTrace?: AuctionTraceSummary; + /** Tester-only immutable diagnostic API. */ + adTrace?: AdTraceApi; + /** Private recorder installed only by the optional ad_trace module. */ + recordAdTrace?: (observation: AdTraceObservation) => void; + /** Private generation allocator installed only by the optional module. */ + nextAdTraceGeneration?: (slotId: string) => number; + /** Private overlay subscription installed only by the optional module. */ + subscribeAdTrace?: (listener: () => void) => () => void; + /** Bind one generation to the exact DOM element captured at its request boundary. */ + bindAdTraceElement?: (slotId: string, generation: number, element: HTMLElement) => void; + /** Resolve only that exact captured element; never searches replacement DOM. */ + getAdTraceElement?: (slotId: string, generation: number) => HTMLElement | undefined; + /** Private live visibility updater used only by the active overlay. */ + updateAdTraceVisibility?: ( + slotId: string, + generation: number, + visibility: RenderTraceVisibility + ) => void; + /** Private request-scoped Prebid correlation ledger; never exported. */ + prebidCorrelation?: Array<{ + auctionId: string; + slotId: string; + requestId: string; + bidder?: string; + adId?: string; + traceToken?: string; + serverTrace?: TrustedServerBidTrace; + events?: AdTraceEventKind[]; + }>; + /** Exact selected participants retained briefly for post-request terminal events. */ + prebidSelectedParticipants?: Array<{ + auctionId: string; + slotId: string; + requestId: string; + adId?: string; + traceToken?: string; + bidder?: string; + generation: number; + selectedAt: number; + }>; + /** Request-scoped root summaries retained until the GPT request boundary. */ + prebidServerSummaries?: Array<{ + auctionId: string; + slotId: string; + summary: AuctionTraceSummary; + }>; + /** Completed Prebid auctions used to identify request-scoped no-bid selections. */ + prebidCompletedAuctions?: Array<{ auctionId: string; slotIds: string[] }>; + /** Private bootstrap queue used until the GPT module installs its capture hook. */ + pendingAdTraceRequests?: Array<{ + slot: unknown; + trigger: string; + snapshot?: { + slotId?: string; + bidder?: string; + adId?: string; + traceToken?: string; + bid?: AuctionBidData; + }; + }>; + /** Private request-boundary hook shared with bootstrap and slim Prebid. */ + captureAdTraceRequest?: ( + slot: unknown, + trigger: string, + snapshot?: { + slotId?: string; + bidder?: string; + adId?: string; + traceToken?: string; + bid?: AuctionBidData; + } + ) => number; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ adInit?: () => void; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ @@ -98,12 +294,6 @@ export interface TsjsApi { servicesEnabled?: boolean; /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ divToSlotId?: Record; - /** - * Win/billing beacons already fired, keyed by `slotId|bidIdentity|kind|url`. - * Used by the GPT render bridge so a bid's nurl/burl fire at most once even - * across repeated Prebid Universal Creative requests for the same adId. - */ - firedBeacons?: Record; /** Slot-level GPT targeting keys TS applied on the previous route. */ prevSlotTargetingKeys?: Record; /** diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts new file mode 100644 index 000000000..cf6d6d33c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts @@ -0,0 +1,98 @@ +import { createAdTraceStore, isBoundedTraceLabel, isCanonicalTraceUuid } from '../../core/ad_trace'; +import type { AdTraceApi, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; + +import { installAdTraceOverlay } from './overlay'; + +const TRACE_SOURCES = new Set(['initial_navigation', 'spa_navigation', 'auction_api']); +const TRACE_OUTCOMES = new Set(['completed', 'no_bid', 'skipped', 'failed', 'abandoned']); + +function validSummary(value: AuctionTraceSummary | undefined): value is AuctionTraceSummary { + return ( + value?.version === 1 && + isCanonicalTraceUuid(value.auctionTraceId) && + TRACE_SOURCES.has(value.source) && + TRACE_OUTCOMES.has(value.outcome) + ); +} + +function validBid(value: AuctionBidData | undefined, slotId: string): boolean { + const trace = value?.trace; + return !!( + trace?.version === 1 && + trace.slotId === slotId && + isCanonicalTraceUuid(trace.auctionTraceId) && + isCanonicalTraceUuid(trace.bidTraceId) && + isBoundedTraceLabel(trace.provider) && + isBoundedTraceLabel(trace.bidder) + ); +} + +function consumeActiveBootstrap(): boolean { + if (window.__tsjs_adTraceActive !== true) return false; + delete window.__tsjs_adTraceActive; + return true; +} + +/** Install the session-scoped recorder, immutable API, and overlay once. */ +export function installAdTrace(): boolean { + if (typeof window === 'undefined') return false; + if (window.tsjs?.adTrace) return true; + if (!consumeActiveBootstrap()) return false; + const ts = (window.tsjs ??= {} as TsjsApi); + + const store = createAdTraceStore(); + const api: AdTraceApi = Object.freeze({ + getSlot: store.getSlot, + getEvents: store.getEvents, + getRenderTimeline: store.getRenderTimeline, + export: store.export, + }); + ts.adTrace = api; + ts.recordAdTrace = store.record; + ts.nextAdTraceGeneration = store.nextGeneration; + ts.subscribeAdTrace = store.subscribe; + ts.bindAdTraceElement = store.bindElement; + ts.getAdTraceElement = store.getBoundElement; + ts.updateAdTraceVisibility = store.updateVisibility; + if (!ts.captureAdTraceRequest) { + ts.captureAdTraceRequest = (slot, trigger, snapshot) => { + const pending = (ts.pendingAdTraceRequests ??= []); + if (pending.length < 64) pending.push({ slot, trigger, snapshot }); + return 0; + }; + } + + const summary = validSummary(ts.auctionTrace) ? ts.auctionTrace : undefined; + for (const slot of ts.adSlots ?? []) { + const bid = ts.bids?.[slot.id]; + if (validBid(bid, slot.id) && bid?.trace) { + store.record({ + kind: 'ts_winner_observed', + slotId: slot.id, + auctionTraceId: bid.trace.auctionTraceId, + bidTraceId: bid.trace.bidTraceId, + provider: bid.trace.provider, + bidder: bid.trace.bidder, + }); + } else if (summary) { + store.record({ + kind: 'ts_auction_observed', + slotId: slot.id, + auctionTraceId: summary.auctionTraceId, + outcome: + summary.outcome === 'completed' || summary.outcome === 'no_bid' + ? 'no_bid' + : summary.outcome === 'skipped' + ? 'skipped' + : 'unresolved', + confidence: 'definitive', + reason: 'terminal_summary', + }); + } + } + + installAdTraceOverlay(api, store.subscribe); + return true; +} + +if (typeof window !== 'undefined') installAdTrace(); diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts new file mode 100644 index 000000000..fd08963d0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts @@ -0,0 +1,231 @@ +import type { + AdTraceApi, + RenderTraceSnapshot, + RenderTraceVisibility, + SlotTraceSnapshot, +} from '../../core/types'; + +const HOST_ID = 'ts-ad-trace-overlay'; +const TRACE_ATTRIBUTES = [ + 'data-ts-trace-seq', + 'data-ts-trace-generation', + 'data-ts-auction-trace-id', + 'data-ts-bid-trace-id', + 'data-ts-trace-outcome', + 'data-ts-trace-visibility', +] as const; + +function stageLine(label: string, stage: { outcome: string; confidence: string }): string { + return `${label}: ${stage.outcome} · ${stage.confidence}`; +} + +function badgeText(slot: SlotTraceSnapshot, render?: RenderTraceSnapshot): string { + return [ + render ? `#${render.sequence}: ${render.outcome} · ${render.visibility}` : undefined, + stageLine('TS winner', slot.stages.trustedServer), + stageLine('Prebid winner', slot.stages.prebid), + stageLine('GAM result', slot.stages.gam), + stageLine('Creative', slot.stages.creative), + ] + .filter(Boolean) + .join('\n'); +} + +function removeTraceAttributes(element: HTMLElement): void { + for (const attribute of TRACE_ATTRIBUTES) element.removeAttribute(attribute); +} + +function effectiveVisibility(element: HTMLElement, rect: DOMRect): RenderTraceVisibility { + if (!element.isConnected) return 'disconnected'; + if (rect.width <= 0 || rect.height <= 0) return 'hidden'; + let current: HTMLElement | null = element; + while (current) { + const style = getComputedStyle(current); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return 'hidden'; + } + current = current.parentElement; + } + return 'visible'; +} + +function stampRender(element: HTMLElement, render: RenderTraceSnapshot): void { + removeTraceAttributes(element); + element.setAttribute('data-ts-trace-seq', String(render.sequence)); + element.setAttribute('data-ts-trace-generation', String(render.generation)); + element.setAttribute('data-ts-trace-outcome', render.outcome); + element.setAttribute('data-ts-trace-visibility', render.visibility); + if (render.auctionTraceId) + element.setAttribute('data-ts-auction-trace-id', render.auctionTraceId); + if (render.bidTraceId) element.setAttribute('data-ts-bid-trace-id', render.bidTraceId); +} + +/** Install one read-only Shadow DOM trace console. */ +export function installAdTraceOverlay( + api: AdTraceApi, + subscribe: (fn: () => void) => () => void +): void { + if (document.getElementById(HOST_ID)) return; + const host = document.createElement('div'); + host.id = HOST_ID; + const root = host.attachShadow({ mode: 'closed' }); + const style = document.createElement('style'); + style.textContent = ` + :host { all: initial; } + .badge { position: fixed; z-index: 2147483647; max-width: 300px; padding: 6px 8px; + border: 1px solid #72e0a6; border-radius: 4px; background: rgba(10,18,16,.94); + color: #eefbf4; font: 11px/1.35 ui-monospace, monospace; white-space: pre; cursor: pointer; } + .badge.probable { border-color: #67a8ff; } + .panel { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: 460px; + max-height: 60vh; overflow: auto; padding: 10px; background: #0a1210; color: #eefbf4; + border: 1px solid #72e0a6; font: 11px/1.4 ui-monospace, monospace; } + .controls { display: flex; gap: 6px; position: sticky; top: 0; background: #0a1210; } + .warning { color: #ffd479; margin: 6px 0; } + .row { border-top: 1px solid #29443a; padding: 6px 0; } + .row strong { color: #72e0a6; } + button { margin-bottom: 6px; } pre { white-space: pre-wrap; }`; + root.appendChild(style); + const badgeLayer = document.createElement('div'); + const panel = document.createElement('div'); + panel.className = 'panel'; + const controls = document.createElement('div'); + controls.className = 'controls'; + const collapseButton = document.createElement('button'); + collapseButton.textContent = 'Collapse'; + const exportButton = document.createElement('button'); + exportButton.textContent = 'Export trace'; + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close'; + const warning = document.createElement('div'); + warning.className = 'warning'; + warning.textContent = 'A non-empty GAM response alone is not proof of a Trusted Server creative.'; + const rows = document.createElement('div'); + const details = document.createElement('pre'); + details.hidden = true; + controls.append(collapseButton, exportButton, closeButton); + panel.append(controls, warning, rows, details); + root.append(badgeLayer, panel); + document.documentElement.appendChild(host); + let cleanup = (): void => {}; + + exportButton.addEventListener('click', () => { + const blob = new Blob([JSON.stringify(api.export(), null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'trusted-server-ad-trace.json'; + link.click(); + URL.revokeObjectURL(url); + }); + collapseButton.addEventListener('click', () => { + rows.hidden = !rows.hidden; + warning.hidden = rows.hidden; + collapseButton.textContent = rows.hidden ? 'Expand' : 'Collapse'; + }); + closeButton.addEventListener('click', () => { + cleanup(); + host.remove(); + }); + + let observedElements = new Set(); + const resizeObserver = + typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => schedule()); + + const render = (): void => { + badgeLayer.replaceChildren(); + rows.replaceChildren(); + const exported = api.export(); + const slotById = new Map(exported.slots.map((slot) => [slot.slotId, slot])); + const latestBySlot = new Map(); + for (const item of exported.renders) latestBySlot.set(item.slotId, item); + const nextObserved = new Set(); + + for (const item of [...exported.renders].reverse()) { + const row = document.createElement('div'); + row.className = 'row'; + const title = document.createElement('strong'); + title.textContent = `#${item.sequence} ${item.slotId} · ${item.source}`; + const summary = document.createElement('div'); + summary.textContent = `${item.outcome} · ${item.confidence} · ${item.visibility}`; + row.append(title, summary); + row.addEventListener('click', () => { + details.hidden = false; + details.textContent = JSON.stringify( + { render: item, stages: slotById.get(item.slotId)?.stages }, + null, + 2 + ); + }); + rows.appendChild(row); + } + + for (const [slotId, slot] of slotById) { + const item = latestBySlot.get(slotId); + const element = item ? window.tsjs?.getAdTraceElement?.(slotId, item.generation) : undefined; + if (!element || !item) continue; + const rect = element.getBoundingClientRect(); + const visibility = effectiveVisibility(element, rect); + window.tsjs?.updateAdTraceVisibility?.(slotId, item.generation, visibility); + const effectiveItem = visibility === item.visibility ? item : { ...item, visibility }; + if (visibility === 'disconnected') { + resizeObserver?.unobserve(element); + removeTraceAttributes(element); + continue; + } + nextObserved.add(element); + if (!observedElements.has(element)) resizeObserver?.observe(element); + stampRender(element, effectiveItem); + const badge = document.createElement('div'); + badge.className = `badge ${item.outcome === 'confirmed' ? '' : 'probable'}`; + badge.textContent = badgeText(slot, effectiveItem); + badge.style.left = `${Math.max(0, rect.left)}px`; + badge.style.top = `${Math.max(0, rect.top)}px`; + badge.addEventListener('click', () => { + panel.hidden = false; + details.hidden = false; + details.textContent = JSON.stringify( + { render: effectiveItem, stages: slot.stages }, + null, + 2 + ); + }); + badgeLayer.appendChild(badge); + } + for (const element of observedElements) { + if (!nextObserved.has(element)) { + resizeObserver?.unobserve(element); + removeTraceAttributes(element); + } + } + observedElements = nextObserved; + }; + + let framePending = false; + const schedule = (): void => { + if (framePending) return; + framePending = true; + requestAnimationFrame(() => { + framePending = false; + if (host.isConnected) render(); + }); + }; + const unsubscribe = subscribe(schedule); + let cleaned = false; + cleanup = (): void => { + if (cleaned) return; + cleaned = true; + unsubscribe(); + resizeObserver?.disconnect(); + for (const element of observedElements) removeTraceAttributes(element); + window.removeEventListener('scroll', schedule); + window.removeEventListener('resize', schedule); + lifecycleObserver.disconnect(); + }; + const lifecycleObserver = new MutationObserver(() => { + if (!host.isConnected) cleanup(); + }); + lifecycleObserver.observe(document.documentElement, { childList: true, subtree: true }); + window.addEventListener('scroll', schedule, { passive: true }); + window.addEventListener('resize', schedule, { passive: true }); + render(); +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca4689684..63aca2833 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import type { AuctionSlot, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -32,7 +32,11 @@ const TS_BID_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; -const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const; +const TS_BASE_TARGETING_KEYS = [ + ...TS_BID_TARGETING_KEYS, + TS_INITIAL_TARGETING_KEY, + 'ts_trace', +] as const; // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) @@ -48,8 +52,189 @@ interface GoogleTagSlot { } interface SlotRenderEndedEvent { - isEmpty: boolean; + isEmpty?: boolean; + isBackfill?: boolean; + slot: GoogleTagSlot; +} + +interface GptSlotEvent { slot: GoogleTagSlot; + isEmpty?: boolean; + isBackfill?: boolean; +} + +interface RenderCandidate { + slotId: string; + generation: number; + slot: GoogleTagSlot; + divId: string; + /** Renderable only when this record's own hb_adid matches the request snapshot. */ + bid?: Readonly; + adId?: string; + traceToken?: string; + createdAt: number; + terminal: boolean; + consumed: boolean; + superseded: boolean; +} + +interface ExpectedRender { + candidate: RenderCandidate; + source: MessageEventSource; + expiresAt: number; + consumed: boolean; +} + +interface AdTraceRequestBoundarySnapshot { + slotId?: string; + bidder?: string; + adId?: string; + traceToken?: string; + bid?: AuctionBidData; +} + +const requestCandidates = new Map(); +const expectedRenders = new Map(); +const fallbackGenerations = new Map(); + +const MAX_EXPECTED_RENDERS = 200; +const MAX_FALLBACK_GENERATIONS = 200; +const MAX_ACTIVE_CACHE_RENDERS = 64; +const MAX_PRIVATE_REQUEST_OWNERS = 64; +let privateNavigationGeneration = 0; + +interface PrivateRequestOwner { + slotId: string; + adId?: string; + bid?: Readonly; + generation?: number; + element: HTMLElement | null; + navigationGeneration: number; + expiresAt: number; + served: boolean; +} + +const latestPrivateRequestBySlot = new Map(); +const staleTsAdIdBits = new Uint32Array(64); + +function staleAdIdHashes(value: string): [number, number] { + let first = 2166136261; + let second = 5381; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + first = Math.imul(first ^ code, 16777619) >>> 0; + second = (Math.imul(second, 33) ^ code) >>> 0; + } + return [first % 2048, second % 2048]; +} + +function rememberStaleAdIdBits(adId: string): void { + for (const hash of staleAdIdHashes(adId)) { + staleTsAdIdBits[hash >>> 5] |= 1 << (hash & 31); + } +} + +function staleAdIdBitsContain(adId: string): boolean { + return staleAdIdHashes(adId).every( + (hash) => (staleTsAdIdBits[hash >>> 5] & (1 << (hash & 31))) !== 0 + ); +} + +interface ActiveCacheRender { + controller: AbortController; + slotId: string; + adId: string; + source: MessageEventSource | null; + generation?: number; + candidate?: RenderCandidate; + cacheHost: string; + cachePath: string; + traceToken?: string; + navigationGeneration: number; + expiresAt: number; + expiryTimer?: ReturnType; +} + +const activeCacheRenders = new Set(); +const latestCacheRenderBySlot = new Map(); + +function rememberStaleTsOwner(owner: PrivateRequestOwner): void { + if (!owner.bid || !owner.adId) return; + rememberStaleAdIdBits(owner.adId); +} + +function retireActiveCacheRender(render: ActiveCacheRender): void { + if (render.expiryTimer) clearTimeout(render.expiryTimer); + render.controller.abort(); + activeCacheRenders.delete(render); + if (latestCacheRenderBySlot.get(render.slotId) === render) { + latestCacheRenderBySlot.delete(render.slotId); + } +} + +function invalidatePrivateRequestOwners(slotId?: string): void { + const entries = slotId + ? [[slotId, latestPrivateRequestBySlot.get(slotId)] as const] + : [...latestPrivateRequestBySlot.entries()]; + for (const [key, owner] of entries) { + if (!owner) continue; + rememberStaleTsOwner(owner); + latestPrivateRequestBySlot.delete(key); + } +} + +function abortActiveCacheRenders(slotId?: string): void { + privateNavigationGeneration += slotId ? 0 : 1; + for (const render of [...activeCacheRenders]) { + if (!slotId || render.slotId === slotId) retireActiveCacheRender(render); + } + invalidatePrivateRequestOwners(slotId); +} + +function claimPrivateRequestOwner( + slotId: string, + adId: string | undefined, + bid: Readonly | undefined, + element: HTMLElement | null +): PrivateRequestOwner { + for (const render of [...activeCacheRenders]) { + if (render.slotId === slotId) retireActiveCacheRender(render); + } + const previous = latestPrivateRequestBySlot.get(slotId); + if (previous) rememberStaleTsOwner(previous); + const owner: PrivateRequestOwner = { + slotId, + adId, + bid, + element, + navigationGeneration: privateNavigationGeneration, + expiresAt: monotonicNow() + 30_000, + served: false, + }; + latestPrivateRequestBySlot.delete(slotId); + latestPrivateRequestBySlot.set(slotId, owner); + while (latestPrivateRequestBySlot.size > MAX_PRIVATE_REQUEST_OWNERS) { + const oldest = latestPrivateRequestBySlot.keys().next().value as string | undefined; + if (!oldest) break; + const evicted = latestPrivateRequestBySlot.get(oldest); + if (evicted) rememberStaleTsOwner(evicted); + latestPrivateRequestBySlot.delete(oldest); + for (const render of [...activeCacheRenders]) { + if (render.slotId === oldest) retireActiveCacheRender(render); + } + } + return owner; +} + +function isKnownStaleTsAdId(adId: string): boolean { + // The fixed-size bitset intentionally never forgets within the page session: + // false positives fail closed, while bounded-map eviction cannot create a + // false negative that lets a stale TS Universal Creative fall through. + return staleAdIdBitsContain(adId); +} + +function monotonicNow(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); } function findSlotElementByDivId(divId: string): HTMLElement | null { @@ -103,7 +288,7 @@ interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; - addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; + addEventListener(event: string, fn: (e: GptSlotEvent) => void): void; refresh(slots?: GoogleTagSlot[]): void; getSlots?(): GoogleTagSlot[]; disableInitialLoad?(): void; @@ -128,6 +313,39 @@ type GptWindow = Window & { __tsjs_slim_prebid_url?: string; }; +const cacheInvalidationHookedTags = new WeakSet(); +const cacheInvalidationHookedSlots = new WeakSet(); + +function installSlotCacheInvalidationHook(slot: GoogleTagSlot): void { + if (cacheInvalidationHookedSlots.has(slot) || typeof slot.clearTargeting !== 'function') return; + const original = slot.clearTargeting.bind(slot); + slot.clearTargeting = (key?: string) => { + const slotId = slotIdForGptSlot(slot); + if (slotId) abortActiveCacheRenders(slotId); + return original(key); + }; + cacheInvalidationHookedSlots.add(slot); +} + +function installGoogleTagCacheInvalidationHooks(g: Partial): void { + if (cacheInvalidationHookedTags.has(g)) return; + if (typeof g.destroySlots === 'function') { + const original = g.destroySlots.bind(g); + g.destroySlots = (slots?: GoogleTagSlot[]) => { + if (slots) { + slots.forEach((slot) => { + const slotId = slotIdForGptSlot(slot); + if (slotId) abortActiveCacheRenders(slotId); + }); + } else { + abortActiveCacheRenders(); + } + return original(slots); + }; + } + cacheInvalidationHookedTags.add(g); +} + // ------------------------------------------------------------------ // Shim implementation // ------------------------------------------------------------------ @@ -345,24 +563,40 @@ function injectAdmIntoSlot(divId: string, adm: string): void { } } -function fireWinBillingBeacons(slotId: string, bid: AuctionBidData): void { - if (!slotId || (!bid.nurl && !bid.burl)) return; +const MAX_BILLING_DEDUPE_KEYS = 512; +const BILLING_DEDUPE_TTL_MS = 30 * 60_000; +const firedBillingKeys = new Map(); - const fired = (window.tsjs!.firedBeacons ??= {}); +function billingEntries(slotId: string, bid: AuctionBidData): Array<[string, string]> { const bidIdentity = bid.hb_adid ?? bid.nurl ?? bid.burl ?? ''; - const urls = [ - ['nurl', bid.nurl], - ['burl', bid.burl], - ] as const; - - for (const [kind, url] of urls) { - if (!url) continue; + return ( + [ + ['nurl', bid.nurl], + ['burl', bid.burl], + ] as const + ).flatMap(([kind, url]) => + url ? [[`${slotId}|${bidIdentity}|${kind}|${url}`, url] as [string, string]] : [] + ); +} - const beaconKey = `${slotId}|${bidIdentity}|${kind}|${url}`; - if (fired[beaconKey]) continue; +function billingCapacityAvailable(slotId: string, bid: AuctionBidData): boolean { + const now = monotonicNow(); + for (const [key, expiresAt] of firedBillingKeys) { + if (expiresAt <= now) firedBillingKeys.delete(key); + } + const additional = billingEntries(slotId, bid).filter( + ([key]) => !firedBillingKeys.has(key) + ).length; + return firedBillingKeys.size + additional <= MAX_BILLING_DEDUPE_KEYS; +} +function fireWinBillingBeacons(slotId: string, bid: AuctionBidData): void { + if (!slotId) return; + const now = monotonicNow(); + for (const [key, url] of billingEntries(slotId, bid)) { + if (firedBillingKeys.has(key)) continue; if (queueWinBillingBeacon(url)) { - fired[beaconKey] = true; + firedBillingKeys.set(key, now + BILLING_DEDUPE_TTL_MS); } } } @@ -445,8 +679,363 @@ function installInitialLoadDetector(ts: TsjsApi): void { }); } +function slotIdForGptSlot(slot: GoogleTagSlot): string | undefined { + const divId = slot.getSlotElementId?.() ?? ''; + return ( + window.tsjs?.divToSlotId?.[divId] ?? + window.tsjs?.adSlots?.find((item) => { + return ( + divId === item.div_id || + divId === `${item.div_id}-container` || + divId.startsWith(item.div_id) + ); + })?.id + ); +} + +function firstSlotTarget(slot: GoogleTagSlot, key: string): string | undefined { + return slot.getTargeting?.(key)?.find((value) => value.length > 0); +} + +function supersedeCandidate(candidate: RenderCandidate, reason: string): void { + if (candidate.superseded) return; + candidate.superseded = true; + for (const render of [...activeCacheRenders]) { + if (render.candidate === candidate) retireActiveCacheRender(render); + } + window.tsjs?.recordAdTrace?.({ + kind: 'generation_superseded', + slotId: candidate.slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + reason, + }); +} + +export function supersedeAdTraceSlot(slot: GoogleTagSlot, reason: string): void { + const slotId = slotIdForGptSlot(slot); + if (slotId) { + abortActiveCacheRenders(slotId); + if (window.tsjs?.prebidSelectedParticipants) { + window.tsjs.prebidSelectedParticipants = window.tsjs.prebidSelectedParticipants.filter( + (entry) => entry.slotId !== slotId + ); + } + } + for (const candidates of requestCandidates.values()) { + candidates + .filter((candidate) => candidate.slot === slot && !candidate.superseded) + .forEach((candidate) => supersedeCandidate(candidate, reason)); + } +} + +/** Capture immutable attribution immediately before one concrete GPT request. */ +export function captureAdTraceRequest( + slot: GoogleTagSlot, + trigger: string, + snapshot?: AdTraceRequestBoundarySnapshot +): number { + const ts = window.tsjs; + const hasBoundarySnapshot = snapshot !== undefined; + const slotId = hasBoundarySnapshot ? snapshot.slotId : slotIdForGptSlot(slot); + if (!slotId) return 0; + installSlotCacheInvalidationHook(slot); + + // Private service ownership is captured for every GPT request, even when the + // diagnostic recorder is disabled. It must precede all asynchronous render + // work so a later request or navigation can invalidate the exact owner. + const bidder = hasBoundarySnapshot ? snapshot.bidder : firstSlotTarget(slot, 'hb_bidder'); + const adId = hasBoundarySnapshot ? snapshot.adId : firstSlotTarget(slot, 'hb_adid'); + const rawTraceToken = hasBoundarySnapshot + ? snapshot.traceToken + : firstSlotTarget(slot, 'ts_trace'); + const traceToken = + rawTraceToken && TRACE_TOKEN_RE.test(rawTraceToken) ? rawTraceToken : undefined; + const liveBid = hasBoundarySnapshot ? snapshot.bid : ts?.bids?.[slotId]; + const renderBidMatches = + !!liveBid && + !!adId && + liveBid.hb_adid === adId && + (!traceToken || liveBid.trace?.bidTraceId === traceToken); + const divId = slot.getSlotElementId?.() ?? ''; + const privateBid = renderBidMatches ? Object.freeze({ ...liveBid }) : undefined; + const privateOwner = claimPrivateRequestOwner( + slotId, + adId, + privateBid, + divId ? findSlotElementByDivId(divId) : null + ); + + if (!ts?.recordAdTrace) return 0; + (requestCandidates.get(slotId) ?? []) + .filter((candidate) => !candidate.superseded && !candidate.consumed) + .forEach((candidate) => supersedeCandidate(candidate, 'request_replaced')); + const generation = + ts.nextAdTraceGeneration?.(slotId) ?? (fallbackGenerations.get(slotId) ?? 0) + 1; + privateOwner.generation = generation; + fallbackGenerations.delete(slotId); + fallbackGenerations.set(slotId, generation); + while (fallbackGenerations.size > MAX_FALLBACK_GENERATIONS) { + const oldest = fallbackGenerations.keys().next().value as string | undefined; + if (!oldest) break; + fallbackGenerations.delete(oldest); + } + + // Diagnostic attribution reads the same immutable request-boundary values as + // the private owner, but remains optional and independently gated. + const ledger = ts.prebidCorrelation ?? []; + const selectedMatches = traceToken + ? ledger.filter((entry) => entry.slotId === slotId && entry.traceToken === traceToken) + : adId + ? ledger.filter((entry) => entry.slotId === slotId && entry.adId === adId) + : []; + const selectedParticipant = selectedMatches.length === 1 ? selectedMatches[0] : undefined; + const completedAuction = [...(ts.prebidCompletedAuctions ?? [])] + .reverse() + .find((entry) => entry.slotIds.includes(slotId)); + const auctionId = + selectedParticipant?.auctionId ?? (!adId ? completedAuction?.auctionId : undefined); + const participants = auctionId + ? ledger.filter((entry) => entry.slotId === slotId && entry.auctionId === auctionId) + : []; + const hasTracedTsParticipant = participants.some((entry) => !!entry.traceToken); + const tracedServerParticipant = participants.find((entry) => entry.serverTrace); + const serverSummary = auctionId + ? (ts.prebidServerSummaries ?? []).find( + (entry) => entry.auctionId === auctionId && entry.slotId === slotId + )?.summary + : undefined; + if (selectedParticipant) { + const selected = (ts.prebidSelectedParticipants ??= []).filter( + (entry) => monotonicNow() - entry.selectedAt <= 30_000 + ); + selected.push({ + auctionId: selectedParticipant.auctionId, + slotId, + requestId: selectedParticipant.requestId, + adId: selectedParticipant.adId, + traceToken: selectedParticipant.traceToken, + bidder: selectedParticipant.bidder, + generation, + selectedAt: monotonicNow(), + }); + while (selected.length > 128) selected.shift(); + ts.prebidSelectedParticipants = selected; + } + if (auctionId) { + ts.prebidCorrelation = ledger.filter( + (entry) => !(entry.slotId === slotId && entry.auctionId === auctionId) + ); + ts.prebidCompletedAuctions = (ts.prebidCompletedAuctions ?? []).filter( + (entry) => entry.auctionId !== auctionId + ); + ts.prebidServerSummaries = (ts.prebidServerSummaries ?? []).filter( + (entry) => !(entry.auctionId === auctionId && entry.slotId === slotId) + ); + } + + const candidate: RenderCandidate = { + slotId, + generation, + slot, + divId, + ...(privateBid ? { bid: privateBid } : {}), + adId, + traceToken, + createdAt: monotonicNow(), + terminal: false, + consumed: false, + superseded: false, + }; + const capturedElement = candidate.divId ? findSlotElementByDivId(candidate.divId) : null; + if (capturedElement) ts.bindAdTraceElement?.(slotId, generation, capturedElement); + if (!requestCandidates.has(slotId) && requestCandidates.size >= 64) { + const oldestSlotId = requestCandidates.keys().next().value as string | undefined; + if (oldestSlotId) { + requestCandidates + .get(oldestSlotId) + ?.forEach((item) => supersedeCandidate(item, 'slot_evicted')); + requestCandidates.delete(oldestSlotId); + } + } + const candidates = requestCandidates.get(slotId) ?? []; + candidates + .filter((item) => !item.superseded && monotonicNow() - item.createdAt > 30_000) + .forEach((item) => supersedeCandidate(item, 'generation_expired')); + candidates.push(candidate); + if (candidates.length > 8) { + const evicted = candidates.shift(); + if (evicted) supersedeCandidate(evicted, 'generation_evicted'); + } + requestCandidates.set(slotId, candidates); + + const serverTrace = tracedServerParticipant?.serverTrace; + if (serverTrace) { + ts.recordAdTrace({ + kind: 'ts_winner_observed', + slotId, + generation, + auctionTraceId: serverTrace.auctionTraceId, + bidTraceId: serverTrace.bidTraceId, + provider: serverTrace.provider, + bidder: serverTrace.bidder, + }); + } else if (serverSummary) { + ts.recordAdTrace({ + kind: 'ts_auction_observed', + slotId, + generation, + auctionTraceId: serverSummary.auctionTraceId, + outcome: serverSummary.outcome === 'completed' ? 'no_bid' : serverSummary.outcome, + confidence: 'definitive', + reason: 'terminal_summary', + }); + } + + let outcome = 'no_bid'; + let reason = 'no_selected_targeting'; + let confidence: 'definitive' | 'none' = 'definitive'; + if (selectedMatches.length > 1) { + outcome = 'unresolved'; + reason = 'ambiguous_prebid_request'; + confidence = 'none'; + } else if (selectedParticipant) { + if (traceToken && selectedParticipant.traceToken === traceToken) outcome = 'won'; + else if (!traceToken) outcome = hasTracedTsParticipant ? 'lost' : 'client_bid_won'; + else outcome = hasTracedTsParticipant ? 'lost' : 'unresolved'; + reason = 'selected_targeting'; + } else if (completedAuction && !bidder && !adId && !traceToken) { + outcome = 'no_bid'; + reason = 'prebid_no_bid'; + } else if (bidder || adId || traceToken) { + outcome = traceToken && renderBidMatches ? 'not_run' : 'client_bid_won'; + reason = traceToken && renderBidMatches ? 'direct_gpt_request' : 'unjoined_targeting'; + if (!traceToken && !renderBidMatches) confidence = 'none'; + } + ts.recordAdTrace({ + kind: 'prebid_targeting_selected', + slotId, + generation, + bidTraceId: traceToken, + bidder, + outcome, + confidence, + reason, + }); + for (const kind of selectedParticipant?.events ?? []) { + ts.recordAdTrace({ + kind, + slotId, + generation, + bidTraceId: traceToken, + bidder, + }); + } + if (liveBid?.hb_bidder === 'aps' || liveBid?.hb_bidder === 'amazon-aps') { + ts.recordAdTrace({ + kind: 'aps_display_bids_set', + slotId, + generation, + bidTraceId: traceToken, + }); + } + ts.recordAdTrace({ + kind: 'gpt_request_started', + slotId, + generation, + auctionTraceId: liveBid?.trace?.auctionTraceId ?? ts.auctionTrace?.auctionTraceId, + bidTraceId: traceToken, + provider: liveBid?.trace?.provider, + bidder, + reason: trigger, + }); + return generation; +} + +function candidateForSlot( + slot: GoogleTagSlot, + includeTerminal = false +): RenderCandidate | undefined { + const slotId = slotIdForGptSlot(slot); + if (!slotId) return undefined; + const candidates = (requestCandidates.get(slotId) ?? []).filter( + (candidate) => + candidate.slot === slot && + !candidate.superseded && + (includeTerminal || !candidate.terminal) && + monotonicNow() - candidate.createdAt <= 30_000 + ); + if (candidates.length !== 1) { + if (candidates.length > 1) { + candidates.forEach((candidate) => + window.tsjs?.recordAdTrace?.({ + kind: 'gpt_slot_render_ended', + slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + outcome: 'unresolved', + confidence: 'none', + reason: 'overlapping_request', + }) + ); + } else { + window.tsjs?.recordAdTrace?.({ + kind: 'gpt_slot_response_received', + slotId, + outcome: 'unresolved', + confidence: 'none', + reason: 'missing_generation', + }); + } + return undefined; + } + return candidates[0]; +} + +function installGptEvidenceListeners(service: GoogleTagPubAdsService): void { + if (!window.tsjs?.recordAdTrace) return; + const instrumented = service as GoogleTagPubAdsService & { __tsAdTraceListeners?: boolean }; + if (instrumented.__tsAdTraceListeners) return; + instrumented.__tsAdTraceListeners = true; + const record = + (kind: 'gpt_slot_requested' | 'gpt_slot_response_received' | 'gpt_slot_onload') => + (event: GptSlotEvent): void => { + const candidate = candidateForSlot(event.slot, kind === 'gpt_slot_onload'); + if (!candidate) return; + window.tsjs?.recordAdTrace?.({ + kind, + slotId: candidate.slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + }); + }; + service.addEventListener('slotRequested', record('gpt_slot_requested')); + service.addEventListener('slotResponseReceived', record('gpt_slot_response_received')); + service.addEventListener('slotOnload', record('gpt_slot_onload')); + service.addEventListener('slotRenderEnded', (event: GptSlotEvent) => { + const candidate = candidateForSlot(event.slot); + if (!candidate) return; + candidate.terminal = true; + window.tsjs?.recordAdTrace?.({ + kind: 'gpt_slot_render_ended', + slotId: candidate.slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + isEmpty: event.isEmpty, + isBackfill: event.isBackfill, + }); + }); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); + const pendingBootstrapRequests = ts.pendingAdTraceRequests ?? []; + ts.pendingAdTraceRequests = []; + ts.captureAdTraceRequest = (slot, trigger, snapshot) => + captureAdTraceRequest(slot as GoogleTagSlot, trigger, snapshot); + pendingBootstrapRequests.forEach(({ slot, trigger, snapshot }) => + ts.captureAdTraceRequest?.(slot, trigger, snapshot) + ); installInitialLoadDetector(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; @@ -454,18 +1043,51 @@ export function installTsAdInit(): void { // The slotRenderEnded listener below reads ts.bids live so SPA navigation // updates (new ts.bids injected before ) are picked up at render time. const bids = ts.bids ?? {}; + const summary = ts.auctionTrace; + for (const slot of slots) { + const bid = bids[slot.id]; + if (bid?.trace && TRACE_TOKEN_RE.test(bid.trace.bidTraceId)) { + ts.recordAdTrace?.({ + kind: 'ts_winner_observed', + slotId: slot.id, + auctionTraceId: bid.trace.auctionTraceId, + bidTraceId: bid.trace.bidTraceId, + provider: bid.trace.provider, + bidder: bid.trace.bidder, + }); + } else if (summary) { + ts.recordAdTrace?.({ + kind: 'ts_auction_observed', + slotId: slot.id, + auctionTraceId: summary.auctionTraceId, + outcome: + summary.outcome === 'completed' || summary.outcome === 'no_bid' + ? 'no_bid' + : summary.outcome === 'skipped' + ? 'skipped' + : 'unresolved', + confidence: 'definitive', + reason: 'terminal_summary', + }); + } + } const g = (window as GptWindow).googletag; if (!g) return; g.cmd?.push(() => { + installGoogleTagCacheInvalidationHooks(g); // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + (ts.prevGptSlots as GoogleTagSlot[]).forEach((slot) => + supersedeAdTraceSlot(slot, 'slot_destroyed') + ); g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); ts.prevGptSlots = []; } // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. + installGptEvidenceListeners(g.pubads!()); const newSlots: GoogleTagSlot[] = []; // Publisher-owned slots TS reused — refreshed to pick up server-side // targeting. The publisher already display()ed these. @@ -493,6 +1115,7 @@ export function installTsAdInit(): void { (g.pubads!().getSlots?.() ?? []).forEach((gptSlot: GoogleTagSlot) => { const elementId = gptSlot.getSlotElementId(); if (!prevTouchedDivIds.has(elementId)) return; + supersedeAdTraceSlot(gptSlot, 'targeting_cleared'); clearTargetingKeys(gptSlot, [ ...TS_BASE_TARGETING_KEYS, ...(prevSlotTargetingKeys[elementId] ?? []), @@ -529,6 +1152,7 @@ export function installTsAdInit(): void { tsOwned = true; } + installSlotCacheInvalidationHook(gptSlot); const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; clearTargetingKeys(gptSlot, [ ...TS_BASE_TARGETING_KEYS, @@ -540,7 +1164,18 @@ export function installTsAdInit(): void { TS_BID_TARGETING_KEYS.forEach((key) => { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); + if (bid.trace?.bidTraceId && TRACE_TOKEN_RE.test(bid.trace.bidTraceId)) { + gptSlot.setTargeting('ts_trace', bid.trace.bidTraceId); + } gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + ts.recordAdTrace?.({ + kind: 'gpt_targeting_applied', + slotId: slot.id, + auctionTraceId: bid.trace?.auctionTraceId, + bidTraceId: bid.trace?.bidTraceId, + provider: bid.trace?.provider, + bidder: bid.trace?.bidder, + }); // Map both inner div and container div → slot ID so slotRenderEnded // (which reports the GPT slot's div, i.e. slotDivId/container) can look up // the slot, while adm injection (which targets the inner div) also works. @@ -562,6 +1197,11 @@ export function installTsAdInit(): void { if (bid.hb_bidder === 'aps' || bid.hb_bidder === 'amazon-aps') { // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).apstag?.setDisplayBids?.(); + ts.recordAdTrace?.({ + kind: 'aps_display_bids_set', + slotId: slot.id, + bidTraceId: bid.trace?.bidTraceId, + }); } }); @@ -607,7 +1247,11 @@ export function installTsAdInit(): void { // called without a matching display call") and misses its impression. // Must run after enableServices(); on SPA navigation services are already // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => g.display?.(divId)); + slotsToDisplay.forEach((divId) => { + const gptSlot = newSlots.find((slot) => slot.getSlotElementId() === divId); + if (gptSlot && !ts.gptInitialLoadDisabled) captureAdTraceRequest(gptSlot, 'display'); + g.display?.(divId); + }); // Slots needing an explicit ad request via refresh(). Reused // publisher-owned slots always need one to pick up the just-applied @@ -630,6 +1274,7 @@ export function installTsAdInit(): void { // the same slots still go through the wrapper normally. ts.adInitRefreshInProgress = true; try { + slotsNeedingRefresh.forEach((slot) => captureAdTraceRequest(slot, 'refresh')); g.pubads!().refresh(slotsNeedingRefresh); } finally { ts.adInitRefreshInProgress = false; @@ -640,6 +1285,7 @@ export function installTsAdInit(): void { } interface PageBidsResponse { + auctionTrace?: AuctionTraceSummary; slots: AuctionSlot[]; bids: Record; } @@ -712,16 +1358,20 @@ export function installSpaAuctionHook(): void { // same-pathname back/forward (scroll restoration), and pushState/replaceState // can be called with the current URL, so guard every entry point against // re-requesting impressions for a path we already loaded. - let currentPath = location.pathname; + let currentPath = `${location.pathname}${location.search}`; // Last path whose slots/bids were actually applied — the initial SSR page // counts. A failed navigation rolls `currentPath` back to this rather than to // the immediately-previous committed value: on rapid A→B where A was aborted // mid-flight and B then fails, rolling back to A (never loaded) would strand // it behind the no-op guard, so we roll back to the last applied route instead. - let lastAppliedPath = location.pathname; + let lastAppliedPath = `${location.pathname}${location.search}`; async function onNavigate(path: string): Promise { + // Navigation invalidates private render ownership even when the resulting + // route key is unchanged (for example a state-only replaceState call). + abortActiveCacheRenders(); if (path === currentPath) return; + ts.prebidSelectedParticipants = []; currentPath = path; inflight?.abort(); const controller = new AbortController(); @@ -752,6 +1402,7 @@ export function installSpaAuctionHook(): void { await waitForSlotElements(data.slots, controller.signal); if (inflight !== controller) return; ts.adSlots = data.slots; + ts.auctionTrace = data.auctionTrace; ts.bids = data.bids; // This route is now the committed, loaded state — a later failed // navigation rolls back here, and a return trip no-ops correctly. @@ -778,7 +1429,8 @@ export function installSpaAuctionHook(): void { const original = history[method].bind(history); history[method] = function (state: unknown, unused: string, url?: string | URL | null): void { original(state, unused, url); - const newPath = url ? new URL(String(url), location.href).pathname : location.pathname; + const parsed = url ? new URL(String(url), location.href) : location; + const newPath = `${parsed.pathname}${parsed.search}`; // onNavigate no-ops when newPath equals the last loaded path. void onNavigate(newPath); }; @@ -788,7 +1440,7 @@ export function installSpaAuctionHook(): void { patchHistoryMethod('replaceState'); window.addEventListener('popstate', () => { - void onNavigate(location.pathname); + void onNavigate(`${location.pathname}${location.search}`); }); } @@ -816,9 +1468,53 @@ export function installSlimPrebidLoader(): void { const TS_DISPLAY_RENDERER = '(function(){window.render=function(d,h,w){' + 'var f=h.mkFrame(w.document,{width:d.width||"100%",height:d.height||"100%"});' + + 'if(typeof d.traceToken==="string"){f.addEventListener("load",function(){' + + 'top.postMessage({type:"ts-creative-load",version:1,traceToken:d.traceToken},"*");},{once:true});}' + 'if(d.adUrl&&!d.ad){f.src=d.adUrl;}else{f.srcdoc=d.ad;}' + 'w.document.body.appendChild(f);};})();'; +const TRACE_TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function pruneExpectedRenders(): void { + const now = monotonicNow(); + for (const [token, entries] of expectedRenders) { + const retained = entries.filter((entry) => !entry.consumed && entry.expiresAt >= now); + if (retained.length > 0) expectedRenders.set(token, retained); + else expectedRenders.delete(token); + } +} + +function armExpectedRender( + candidate: RenderCandidate | undefined, + source: MessageEventSource | null +): string | undefined { + if ( + !candidate?.bid || + candidate.superseded || + !candidate.traceToken || + !TRACE_TOKEN_RE.test(candidate.traceToken) || + !source + ) { + return undefined; + } + pruneExpectedRenders(); + const expectedCount = [...expectedRenders.values()].reduce( + (count, entries) => count + entries.length, + 0 + ); + if (expectedCount >= MAX_EXPECTED_RENDERS) return undefined; + candidate.consumed = true; + const entries = expectedRenders.get(candidate.traceToken) ?? []; + entries.push({ + candidate, + source, + expiresAt: monotonicNow() + 30_000, + consumed: false, + }); + expectedRenders.set(candidate.traceToken, entries); + return candidate.traceToken; +} + /** * Install the TS → pbRender bridge. * @@ -839,13 +1535,6 @@ const TS_DISPLAY_RENDERER = export function installTsRenderBridge(): void { if (typeof window === 'undefined') return; - // adIds whose PBS Cache render is in flight. `fireWinBillingBeacons` only - // dedups after the async cache fetch resolves, so two Prebid Request messages - // for the same adId arriving before the first fetch settles would both fetch - // and both fire the nurl/burl beacons. Tracking in-flight adIds prevents the - // concurrent double-fire; the entry is cleared once the fetch settles. - const renderingAdIds = new Set(); - window.addEventListener('message', (e: MessageEvent) => { let data: Record; try { @@ -857,6 +1546,43 @@ export function installTsRenderBridge(): void { return; } + if (data['type'] === 'ts-creative-load') { + const token = data['traceToken']; + if (data['version'] !== 1 || typeof token !== 'string' || !TRACE_TOKEN_RE.test(token)) return; + const entries = expectedRenders.get(token) ?? []; + entries + .filter((entry) => !entry.consumed && entry.expiresAt < monotonicNow()) + .forEach((entry) => supersedeCandidate(entry.candidate, 'ack_expired')); + const matches = entries.filter( + (entry) => + !entry.consumed && + !entry.candidate.superseded && + entry.expiresAt >= monotonicNow() && + entry.source === e.source && + (requestCandidates.get(entry.candidate.slotId) ?? []).includes(entry.candidate) + ); + if (matches.length !== 1) { + const candidate = entries[0]?.candidate; + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_rejected', + slotId: candidate?.slotId, + generation: candidate?.generation, + bidTraceId: TRACE_TOKEN_RE.test(token) ? token : undefined, + reason: matches.length > 1 ? 'ambiguous_generation' : 'invalid_acknowledgement', + }); + return; + } + const expected = matches[0]; + expected.consumed = true; + window.tsjs?.recordAdTrace?.({ + kind: 'creative_load_acknowledged', + slotId: expected.candidate.slotId, + generation: expected.candidate.generation, + bidTraceId: token, + }); + return; + } + if (data['message'] !== 'Prebid Request') return; const adId = data['adId'] as string | undefined; if (!adId) return; @@ -866,30 +1592,65 @@ export function installTsRenderBridge(): void { const sourceSlotId = slotIdForMessageSource(e.source); if (!sourceSlotId) return; - // Build reverse map adId → slotId from live window.tsjs.bids. - const bids = window.tsjs?.bids ?? {}; - let slotId: string | undefined; - let matchedBid: (typeof bids)[string] | undefined; - for (const [sid, bid] of Object.entries(bids)) { - if (bid.hb_adid === adId) { - slotId = sid; - matchedBid = bid; - break; + const allCandidates = requestCandidates.get(sourceSlotId) ?? []; + allCandidates + .filter((candidate) => !candidate.superseded && monotonicNow() - candidate.createdAt > 30_000) + .forEach((candidate) => supersedeCandidate(candidate, 'generation_expired')); + const candidates = allCandidates.filter( + (candidate) => + candidate.adId === adId && + !candidate.consumed && + !candidate.superseded && + monotonicNow() - candidate.createdAt <= 30_000 + ); + const exactCandidate = candidates.length === 1 ? candidates[0] : undefined; + window.tsjs?.recordAdTrace?.({ + kind: candidates.length === 1 ? 'pb_render_requested' : 'pb_render_rejected', + slotId: sourceSlotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: + candidates.length === 1 + ? 'exact_generation' + : candidates.length > 1 + ? 'ambiguous_generation' + : 'missing_generation', + }); + + const slotId = sourceSlotId; + const requestOwner = latestPrivateRequestBySlot.get(slotId); + const liveBid = window.tsjs?.bids?.[slotId]; + const ownerCurrent = + !!requestOwner?.bid && + requestOwner.adId === adId && + requestOwner.bid.hb_adid === adId && + requestOwner.navigationGeneration === privateNavigationGeneration && + requestOwner.expiresAt >= monotonicNow() && + !requestOwner.served && + !!requestOwner.element?.isConnected && + findSlotElementByDivId(requestOwner.element.id) === requestOwner.element && + slotIdForMessageSource(e.source) === slotId && + liveBid?.hb_adid === requestOwner.bid.hb_adid && + liveBid.hb_cache_host === requestOwner.bid.hb_cache_host && + liveBid.hb_cache_path === requestOwner.bid.hb_cache_path && + liveBid.trace?.bidTraceId === requestOwner.bid.trace?.bidTraceId; + if (!ownerCurrent || !requestOwner?.bid) { + // A once-TS-owned message must not escape to ordinary Prebid after its + // request owner was replaced or invalidated. + if (isKnownStaleTsAdId(adId) || liveBid?.hb_adid === adId) { + e.stopImmediatePropagation(); } + return; } - - // Not a TS bid — let Prebid.js handle it. - if (!slotId || !matchedBid) return; - - // The requesting iframe's slot must own the resolved adId. Without this an - // iframe under slot A could request slot B's hb_adid and receive slot B's - // creative/dimensions while firing slot B's win/billing beacons. - if (slotId !== sourceSlotId) return; + const matchedBid = requestOwner.bid; const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); const [width, height] = slot?.formats?.[0] ?? [728, 90]; if (matchedBid.adm) { + if (!billingCapacityAvailable(slotId, matchedBid)) return; + const traceToken = armExpectedRender(exactCandidate, e.source); + requestOwner.served = true; e.stopImmediatePropagation(); port.postMessage( JSON.stringify({ @@ -899,9 +1660,16 @@ export function installTsRenderBridge(): void { renderer: TS_DISPLAY_RENDERER, width, height, + ...(traceToken ? { traceToken } : {}), }) ); fireWinBillingBeacons(slotId, matchedBid); + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: exactCandidate?.generation, + bidTraceId: traceToken, + }); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`); return; } @@ -909,19 +1677,114 @@ export function installTsRenderBridge(): void { // No TS render source — let Prebid.js handle it. if (!matchedBid.hb_cache_host || !matchedBid.hb_cache_path) return; - // TS owns this adId — stop Prebid from also processing it. + const capturedSource = e.source; + const capturedElement = requestOwner.element; + const capturedCacheHost = matchedBid.hb_cache_host; + const capturedCachePath = matchedBid.hb_cache_path; + const capturedTraceToken = matchedBid.trace?.bidTraceId; + + const previousOwner = latestCacheRenderBySlot.get(slotId); + if ( + previousOwner && + previousOwner.adId === adId && + previousOwner.source === capturedSource && + previousOwner.generation === requestOwner.generation && + previousOwner.cacheHost === capturedCacheHost && + previousOwner.cachePath === capturedCachePath && + previousOwner.traceToken === capturedTraceToken && + !previousOwner.controller.signal.aborted && + previousOwner.expiresAt >= monotonicNow() + ) { + // A duplicate message for the exact accepted owner must not start a + // second fetch or escape to the ordinary Prebid renderer. + e.stopImmediatePropagation(); + return; + } + if (previousOwner) retireActiveCacheRender(previousOwner); + + // Capacity overflow must not evict a different live billing owner. Leave + // the message untouched so the ordinary Prebid path can process it. + if (activeCacheRenders.size >= MAX_ACTIVE_CACHE_RENDERS) return; + + const controller = new AbortController(); + const activeRender: ActiveCacheRender = { + controller, + slotId, + adId, + source: capturedSource, + generation: requestOwner.generation, + ...(exactCandidate?.generation === requestOwner.generation + ? { candidate: exactCandidate } + : {}), + cacheHost: capturedCacheHost, + cachePath: capturedCachePath, + traceToken: capturedTraceToken, + navigationGeneration: privateNavigationGeneration, + expiresAt: requestOwner.expiresAt, + }; + + activeCacheRenders.add(activeRender); + latestCacheRenderBySlot.set(slotId, activeRender); + activeRender.expiryTimer = setTimeout( + () => retireActiveCacheRender(activeRender), + Math.max(0, activeRender.expiresAt - monotonicNow()) + ); + // TS owns this accepted render — stop Prebid from also processing it. e.stopImmediatePropagation(); - // Skip a concurrent re-render of the same adId so its win/billing beacons - // fire at most once even before the first cache fetch resolves. - if (renderingAdIds.has(adId)) return; - renderingAdIds.add(adId); + const stillCurrent = (): boolean => { + const liveBid = window.tsjs?.bids?.[slotId]; + const candidateCurrent = + !exactCandidate || + (!exactCandidate.superseded && + (requestCandidates.get(slotId) ?? []).includes(exactCandidate)); + return ( + !controller.signal.aborted && + latestCacheRenderBySlot.get(slotId) === activeRender && + latestPrivateRequestBySlot.get(slotId) === requestOwner && + requestOwner.navigationGeneration === privateNavigationGeneration && + requestOwner.expiresAt >= monotonicNow() && + !requestOwner.served && + activeRender.navigationGeneration === privateNavigationGeneration && + activeRender.expiresAt >= monotonicNow() && + candidateCurrent && + !!capturedElement?.isConnected && + findSlotElementByDivId(capturedElement.id) === capturedElement && + slotIdForMessageSource(capturedSource) === slotId && + liveBid?.hb_adid === adId && + liveBid.hb_cache_host === capturedCacheHost && + liveBid.hb_cache_path === capturedCachePath && + liveBid.trace?.bidTraceId === capturedTraceToken + ); + }; - const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; + const cacheUrl = `https://${capturedCacheHost}${capturedCachePath}?uuid=${encodeURIComponent(adId)}`; - fetch(cacheUrl, { mode: 'cors' }) + fetch(cacheUrl, { mode: 'cors', signal: controller.signal }) .then((res) => (res.ok ? res.text() : Promise.reject(res.status))) .then((ad) => { + if (!stillCurrent()) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_rejected', + slotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: 'stale_cache_completion', + }); + return; + } + if (!billingCapacityAvailable(slotId, matchedBid)) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_rejected', + slotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: 'billing_capacity', + }); + return; + } + const traceToken = armExpectedRender(exactCandidate, capturedSource); + requestOwner.served = true; port.postMessage( JSON.stringify({ message: 'Prebid Response', @@ -930,16 +1793,28 @@ export function installTsRenderBridge(): void { renderer: TS_DISPLAY_RENDERER, width, height, + ...(traceToken ? { traceToken } : {}), }) ); fireWinBillingBeacons(slotId, matchedBid); + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: exactCandidate?.generation, + bidTraceId: traceToken, + }); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; log.warn(`[tsjs-gpt] pbRender bridge: PBS Cache fetch failed for '${slotId}'`, err); }) .finally(() => { - renderingAdIds.delete(adId); + if (activeRender.expiryTimer) clearTimeout(activeRender.expiryTimer); + activeCacheRenders.delete(activeRender); + if (latestCacheRenderBySlot.get(slotId) === activeRender) { + latestCacheRenderBySlot.delete(slotId); + } }); }); } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 342e4038d..b8784d1b4 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -27,9 +27,9 @@ import 'prebid.js/modules/userId.js'; import './_adapters.generated'; import { log } from '../../core/log'; -import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; +import { buildAdRequest, parseAuctionResponse, parseAuctionTraceSummary } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; -import type { AuctionSlot } from '../../core/types'; +import type { AdTraceEventKind, AuctionSlot, TrustedServerBidTrace } from '../../core/types'; import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -47,6 +47,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_adid', 'hb_cache_host', 'hb_cache_path', + 'ts_trace', ] as const; /** Configuration options for the Prebid integration. */ @@ -220,6 +221,12 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: meta: { advertiserDomains: bid.adomain, }, + ...(bid.trace + ? { + adserverTargeting: { ts_trace: bid.trace.bidTraceId }, + tsTrace: bid.trace, + } + : {}), }; }); } @@ -242,6 +249,7 @@ type TrustedServerBidRequest = { adUnitCode?: string; code?: string; bidId?: string; + auctionId?: string; }; type TrustedServerRequest = { method: 'POST'; @@ -458,6 +466,155 @@ function serverSideBidderParamsForRefresh( return params; } +function installAdTracePrebidObservers(): void { + const ts = window.tsjs; + if (!ts?.recordAdTrace) return; + const instrumented = pbjs as unknown as { + __tsAdTraceObserved?: boolean; + onEvent?: (event: string, handler: (data: Record) => void) => void; + setTargetingForGPTAsync?: (codes?: string[]) => unknown; + }; + if (instrumented.__tsAdTraceObserved) return; + instrumented.__tsAdTraceObserved = true; + + const record = + (kind: AdTraceEventKind) => + (data: Record = {}): void => { + const nestedBid = + data.bid && typeof data.bid === 'object' + ? (data.bid as Record) + : undefined; + const evidence = nestedBid ?? data; + const slotId = + typeof evidence.adUnitCode === 'string' + ? evidence.adUnitCode + : typeof evidence.code === 'string' + ? evidence.code + : undefined; + const bidder = + typeof evidence.bidderCode === 'string' + ? evidence.bidderCode + : typeof evidence.bidder === 'string' + ? evidence.bidder + : undefined; + const auctionId = + typeof evidence.auctionId === 'string' + ? evidence.auctionId + : typeof data.auctionId === 'string' + ? data.auctionId + : ''; + const requestId = + typeof evidence.requestId === 'string' + ? evidence.requestId + : typeof evidence.adId === 'string' + ? evidence.adId + : ''; + const adId = typeof evidence.adId === 'string' ? evidence.adId : requestId || undefined; + const targeting = evidence.adserverTargeting as Record | undefined; + const serverTrace = evidence.tsTrace as TrustedServerBidTrace | undefined; + const traceToken = + typeof targeting?.ts_trace === 'string' + ? targeting.ts_trace + : typeof (evidence.tsTrace as { bidTraceId?: unknown } | undefined)?.bidTraceId === + 'string' + ? ((evidence.tsTrace as { bidTraceId: string }).bidTraceId as string) + : undefined; + const ledger = (ts.prebidCorrelation ??= []); + if (kind === 'prebid_bid_response' && auctionId && slotId && requestId) { + ledger.push({ + auctionId, + slotId, + requestId, + bidder, + adId, + traceToken, + serverTrace, + events: [], + }); + if (ledger.length > 256) ledger.shift(); + } else if (kind === 'prebid_auction_end' && auctionId) { + const adUnits = Array.isArray(data.adUnits) + ? (data.adUnits as Array>) + : []; + const received = Array.isArray(data.bidsReceived) + ? (data.bidsReceived as Array>) + : []; + const slotIds = new Set(); + for (const unit of adUnits) { + if (typeof unit.code === 'string') slotIds.add(unit.code); + } + for (const bid of received) { + if (typeof bid.adUnitCode === 'string') slotIds.add(bid.adUnitCode); + } + for (const entry of ledger) { + if (entry.auctionId === auctionId) slotIds.add(entry.slotId); + } + const completed = (ts.prebidCompletedAuctions ??= []); + completed.push({ auctionId, slotIds: [...slotIds] }); + if (completed.length > 64) completed.shift(); + } else if (kind !== 'prebid_auction_init') { + const selected = (ts.prebidSelectedParticipants ?? []).filter( + (entry) => performance.now() - entry.selectedAt <= 30_000 + ); + ts.prebidSelectedParticipants = selected; + const selectedMatches = + auctionId && slotId && requestId + ? selected.filter( + (entry) => + entry.auctionId === auctionId && + entry.slotId === slotId && + (entry.requestId === requestId || entry.adId === adId) && + (!traceToken || entry.traceToken === traceToken) + ) + : []; + if (selectedMatches.length === 1) { + const selectedEntry = selectedMatches[0]; + ts.recordAdTrace?.({ + kind, + slotId, + generation: selectedEntry.generation, + bidTraceId: selectedEntry.traceToken, + bidder: selectedEntry.bidder ?? bidder, + }); + if (kind === 'prebid_render_succeeded' || kind === 'prebid_render_failed') { + ts.prebidSelectedParticipants = selected.filter((entry) => entry !== selectedEntry); + } + return; + } + + const matches = ledger.filter( + (entry) => + (!auctionId || entry.auctionId === auctionId) && + (!slotId || entry.slotId === slotId) && + (!requestId || entry.requestId === requestId || entry.adId === adId) + ); + if (matches.length === 1) { + const events = (matches[0].events ??= []); + events.push(kind); + while (events.length > 16) events.shift(); + } + } + ts.recordAdTrace?.({ kind, slotId, bidder }); + }; + + instrumented.onEvent?.('auctionInit', record('prebid_auction_init')); + instrumented.onEvent?.('bidResponse', record('prebid_bid_response')); + instrumented.onEvent?.('bidWon', record('prebid_bid_won')); + instrumented.onEvent?.('auctionEnd', record('prebid_auction_end')); + instrumented.onEvent?.('adRenderSucceeded', record('prebid_render_succeeded')); + instrumented.onEvent?.('adRenderFailed', record('prebid_render_failed')); + + // Observe the actual selection call once. The GPT request-boundary hook reads + // the resulting slot targeting synchronously; this wrapper never caches it. + const original = instrumented.setTargetingForGPTAsync?.bind(pbjs); + if (!original) return; + instrumented.setTargetingForGPTAsync = function (codes?: string[]) { + const result = original(codes); + ts.recordAdTrace?.({ kind: 'prebid_targeting_selected', reason: 'targeting_applied' }); + return result; + }; +} + function clearRefreshTargeting(slot: RefreshGptSlot): void { if (typeof slot.clearTargeting !== 'function') return; @@ -551,6 +708,16 @@ export function installPrebidNpm(config?: Partial): typeof pbjs log.debug('[tsjs-prebid] interpretResponse', { hasSeatbid: !!body?.seatbid }); const auctionBids = parseAuctionResponse(body); const bidRequests = request?.tsjsBidRequests ?? request?.bidRequests ?? []; + const summary = parseAuctionTraceSummary(body); + if (summary && window.tsjs?.recordAdTrace) { + const summaries = (window.tsjs.prebidServerSummaries ??= []); + for (const bidRequest of bidRequests) { + const auctionId = bidRequest.auctionId; + const slotId = bidRequest.adUnitCode ?? bidRequest.code; + if (auctionId && slotId) summaries.push({ auctionId, slotId, summary }); + } + while (summaries.length > 64) summaries.shift(); + } return auctionBidsToPrebidBids(auctionBids, bidRequests); }, }); @@ -681,6 +848,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // prebid.js via NPM. pbjs.processQueue(); recordUserIdModuleDiagnostics(); + installAdTracePrebidObservers(); // Validate that every client-side bidder has its adapter registered. // Adapters self-register on import, so a missing adapter means the bidder @@ -811,6 +979,9 @@ export function installRefreshHandler(timeoutMs = 1500): void { adUnits, bidsBackHandler: () => { pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + targetSlots.forEach((slot) => + window.tsjs?.captureAdTraceRequest?.(slot, 'prebid_refresh') + ); originalRefresh(targetSlots, opts); }, timeout: timeoutMs, diff --git a/crates/trusted-server-js/lib/test/core/ad_trace.test.ts b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts new file mode 100644 index 000000000..0348e8dd7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; +import { + AD_TRACE_MAX_EVENTS, + AD_TRACE_MAX_GENERATIONS, + AD_TRACE_MAX_RENDERS, + AD_TRACE_MAX_SLOTS, + createAdTraceStore, +} from '../../src/core/ad_trace'; + +const BID_TRACE_ID = '550e8400-e29b-41d4-a716-446655440000'; + +describe('ad trace reducer', () => { + it('bounds events, slots, and retained generations', () => { + let now = 0; + const store = createAdTraceStore(() => ++now); + for (let i = 0; i < AD_TRACE_MAX_EVENTS + 1; i++) { + store.record({ kind: 'prebid_auction_init', reason: 'observed' }); + } + for (let i = 0; i < AD_TRACE_MAX_SLOTS + 1; i++) { + store.nextGeneration(`slot-${i}`); + } + for (let i = 0; i < AD_TRACE_MAX_GENERATIONS + 1; i++) { + store.nextGeneration('latest-slot'); + } + + const exported = store.export(); + expect(exported.events).toHaveLength(AD_TRACE_MAX_EVENTS); + expect(exported.metadata.droppedEvents).toBe(1); + expect(exported.slots).toHaveLength(AD_TRACE_MAX_SLOTS); + expect(store.getSlot('latest-slot')?.generations).toHaveLength(AD_TRACE_MAX_GENERATIONS); + expect(exported.metadata.evictedSlots).toBeGreaterThan(0); + }); + + it('keeps the four stages independent and only acknowledges an exact load event', () => { + const store = createAdTraceStore(() => 10); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'ts_winner_observed', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + isEmpty: false, + }); + + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('trusted_server_candidate'); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + const slot = store.getSlot('slot-a'); + expect(slot?.stages.gam).toMatchObject({ + outcome: 'trusted_server_won', + confidence: 'definitive', + }); + expect(slot?.stages.creative).toMatchObject({ + outcome: 'load_acknowledged', + confidence: 'definitive', + }); + }); + + it('updates only the acknowledged retained generation, never the latest generation', () => { + const store = createAdTraceStore(() => 1); + const first = store.nextGeneration('slot-a'); + const second = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation: first, + bidTraceId: BID_TRACE_ID, + }); + + const slot = store.getSlot('slot-a'); + expect(slot?.latestGeneration).toBe(second); + expect(slot?.stages.creative.outcome).toBe('not_observed'); + expect(slot?.generations[0].stages.creative.outcome).toBe('load_acknowledged'); + expect(slot?.generations[1].stages.creative.outcome).toBe('not_observed'); + }); + + it('never downgrades a definitive acknowledgement with a later GPT callback', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + isEmpty: false, + }); + + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('trusted_server_won'); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('load_acknowledged'); + }); + + it('preserves acknowledged terminal history when its generation is later cleaned up', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'slot_destroyed', + }); + + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('trusted_server_won'); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('load_acknowledged'); + }); + + it('does not rewrite a retained generation when the next auction seeds server evidence', () => { + const store = createAdTraceStore(() => 1); + store.record({ + kind: 'ts_winner_observed', + slotId: 'slot-a', + bidTraceId: BID_TRACE_ID, + }); + const first = store.nextGeneration('slot-a'); + store.record({ + kind: 'ts_auction_observed', + slotId: 'slot-a', + outcome: 'no_bid', + confidence: 'definitive', + reason: 'terminal_summary', + }); + const second = store.nextGeneration('slot-a'); + + const slot = store.getSlot('slot-a'); + expect( + slot?.generations.find((item) => item.generation === first)?.stages.trustedServer.outcome + ).toBe('won'); + expect( + slot?.generations.find((item) => item.generation === second)?.stages.trustedServer.outcome + ).toBe('no_bid'); + }); + + it('classifies overlap, client Prebid, APS, no-bid, and superseded states', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'prebid_targeting_selected', + slotId: 'slot-a', + generation, + outcome: 'client_bid_won', + confidence: 'definitive', + reason: 'selected_targeting', + }); + store.record({ + kind: 'prebid_bid_won', + slotId: 'slot-a', + generation, + }); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: false, + }); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('client_prebid_candidate'); + + store.record({ kind: 'aps_display_bids_set', slotId: 'slot-a', generation }); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('client_prebid_candidate'); + + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'slot_destroyed', + }); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('client_prebid_candidate'); + }); + + it('does not downgrade definitive stage evidence during service or cleanup', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ kind: 'prebid_render_failed', slotId: 'slot-a', generation }); + store.record({ kind: 'pb_render_served', slotId: 'slot-a', generation }); + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'navigation', + }); + + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'render_failed', + confidence: 'definitive', + }); + }); + + it('does not downgrade a definitive empty render outcome', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: true, + }); + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'navigation', + }); + store.record({ kind: 'pb_render_served', slotId: 'slot-a', generation }); + + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'empty', + confidence: 'definitive', + }); + }); + + it('enriches one bounded render record and keeps visibility independent', () => { + let now = 0; + const store = createAdTraceStore(() => ++now); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: false, + }); + store.record({ + kind: 'pb_render_served', + slotId: 'slot-a', + generation, + reason: 'pb_render_response', + }); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.updateVisibility('slot-a', generation, 'hidden'); + + const timeline = store.getRenderTimeline(); + expect(timeline).toHaveLength(1); + expect(timeline[0]).toMatchObject({ + sequence: 1, + outcome: 'confirmed', + confidence: 'definitive', + visibility: 'hidden', + }); + store.updateVisibility('slot-a', generation, 'visible'); + expect(store.getRenderTimeline()[0]).toMatchObject({ + sequence: 1, + outcome: 'confirmed', + confidence: 'definitive', + visibility: 'visible', + }); + }); + + it('dispatches a frozen privacy-safe render event', () => { + const store = createAdTraceStore(() => 1); + const observed: unknown[] = []; + const listener = (event: Event) => observed.push((event as CustomEvent).detail); + window.addEventListener('tsjs:adRendered', listener); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'pb_render_served', + slotId: 'slot-a', + generation, + reason: 'pb_render_response', + rawUrl: 'https://private.example', + } as never); + window.removeEventListener('tsjs:adRendered', listener); + + expect(observed).toHaveLength(1); + expect(Object.isFrozen(observed[0])).toBe(true); + expect(JSON.stringify(observed[0])).not.toContain('private.example'); + }); + + it('bounds the render timeline without duplicating impression generations', () => { + const store = createAdTraceStore(() => 1); + for (let i = 0; i < AD_TRACE_MAX_RENDERS + 1; i++) { + const slotId = `render-${i}`; + const generation = store.nextGeneration(slotId); + store.record({ kind: 'gpt_request_started', slotId, generation }); + } + expect(store.getRenderTimeline()).toHaveLength(AD_TRACE_MAX_RENDERS); + expect(store.getRenderTimeline()[0].slotId).toBe('render-1'); + }); + + it('rejects malformed runtime event kinds and confidence values', () => { + const store = createAdTraceStore(() => 1); + store.record({ kind: 'not-a-real-kind', slotId: 'slot-a' } as never); + store.record({ + kind: 'ts_winner_observed', + slotId: 'slot-a', + confidence: 'certain', + } as never); + expect(store.getEvents()).toHaveLength(0); + }); + + it('exports an immutable sanitized clone', () => { + const store = createAdTraceStore(() => 1); + store.record({ + kind: 'pb_render_rejected', + slotId: 'slot-a', + reason: 'missing_generation', + // Ensure unknown private fields cannot enter the public export. + rawUrl: 'https://private.example/path', + } as never); + + const exported = store.export(); + expect(Object.isFrozen(exported)).toBe(true); + expect(JSON.stringify(exported)).not.toContain('private.example'); + expect(() => exported.events.push({} as never)).toThrow(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 31e020eff..0d8c0a5a1 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/core/auction'; +import { + buildAdRequest, + parseAuctionResponse, + parseAuctionTraceSummary, + sendAuction, +} from '../../src/core/auction'; describe('auction/buildAdRequest', () => { it('builds from tsjs AdUnit objects', () => { @@ -205,6 +210,100 @@ describe('auction/parseAuctionResponse', () => { expect(parseAuctionResponse({ seatbid: [] })).toEqual([]); }); + it('strictly joins valid root and bid traces without changing legacy fields', () => { + const body = { + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: '650e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome: 'completed', + }, + }, + }, + seatbid: [ + { + seat: 'example-bidder', + bid: [ + { + impid: 'slot-1', + price: 1.5, + ext: { + trusted_server: { + trace: { + version: 1, + bid_trace_id: '550e8400-e29b-41d4-a716-446655440000', + slot_id: 'slot-1', + provider: 'prebid', + bidder: 'example-bidder', + }, + }, + }, + }, + ], + }, + ], + }; + + expect(parseAuctionTraceSummary(body)).toEqual({ + version: 1, + auctionTraceId: '650e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome: 'completed', + }); + expect(parseAuctionResponse(body)[0].trace).toEqual({ + version: 1, + auctionTraceId: '650e8400-e29b-41d4-a716-446655440000', + bidTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + slotId: 'slot-1', + provider: 'prebid', + bidder: 'example-bidder', + }); + }); + + it('ignores malformed, contradictory, mismatched, and oversized trace fields', () => { + const body = { + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: '650e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome: 'no_bid', + }, + }, + }, + seatbid: [ + { + seat: 'seat', + bid: [ + { + impid: 'slot-1', + price: 1, + ext: { + trusted_server: { + trace: { + version: 1, + bid_trace_id: '550e8400-e29b-41d4-a716-446655440000', + slot_id: 'different-slot', + provider: 'p'.repeat(65), + bidder: 'seat', + }, + }, + }, + }, + ], + }, + ], + }; + expect(parseAuctionTraceSummary(body)?.outcome).toBe('no_bid'); + expect(parseAuctionResponse(body)[0].trace).toBeUndefined(); + body.ext.trusted_server.trace.auction_trace_id = 'not-a-uuid'; + expect(parseAuctionTraceSummary(body)).toBeUndefined(); + }); + it('defaults missing fields gracefully', () => { const body = { seatbid: [{ bid: [{ impid: 'slot-1', price: 1.5 }] }], @@ -259,7 +358,7 @@ describe('auction/sendAuction', () => { ], }; - const bids = await sendAuction('/auction', request); + const result = await sendAuction('/auction', request); expect(globalThis.fetch).toHaveBeenCalledWith( '/auction', @@ -269,18 +368,46 @@ describe('auction/sendAuction', () => { body: JSON.stringify(request), }) ); - expect(bids).toHaveLength(1); - expect(bids[0].price).toBe(2.5); + expect(result.kind).toBe('ok'); + if (result.kind !== 'ok') throw new Error('expected successful auction'); + expect(result.bids).toHaveLength(1); + expect(result.bids[0].price).toBe(2.5); }); - it('returns empty array on network error', async () => { + it('distinguishes a network error from a valid empty auction', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error')) as any; - const bids = await sendAuction('/auction', { adUnits: [] }); - expect(bids).toEqual([]); + const result = await sendAuction('/auction', { adUnits: [] }); + expect(result).toEqual({ kind: 'transport_error', reason: 'network' }); + }); + + it('accepts legacy empty but rejects malformed seatbid collections', async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({}), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ seatbid: {} }), + }) as any; + + await expect(sendAuction('/auction', { adUnits: [] })).resolves.toEqual({ + kind: 'ok', + bids: [], + }); + await expect(sendAuction('/auction', { adUnits: [] })).resolves.toEqual({ + kind: 'invalid_response', + reason: 'invalid_shape', + }); }); - it('returns empty array for non-JSON response', async () => { + it('distinguishes a non-JSON response', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, @@ -288,11 +415,11 @@ describe('auction/sendAuction', () => { json: async () => ({}), }) as any; - const bids = await sendAuction('/auction', { adUnits: [] }); - expect(bids).toEqual([]); + const result = await sendAuction('/auction', { adUnits: [] }); + expect(result).toEqual({ kind: 'invalid_response', reason: 'non_json' }); }); - it('returns empty array for non-OK response', async () => { + it('distinguishes a non-OK response', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, @@ -300,7 +427,7 @@ describe('auction/sendAuction', () => { json: async () => ({}), }) as any; - const bids = await sendAuction('/auction', { adUnits: [] }); - expect(bids).toEqual([]); + const result = await sendAuction('/auction', { adUnits: [] }); + expect(result).toEqual({ kind: 'transport_error', reason: 'http' }); }); }); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 2c56361dc..80da9fbae 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -10,6 +10,7 @@ describe('request.requestAds', () => { beforeEach(async () => { await vi.resetModules(); document.body.innerHTML = ''; + delete window.tsjs; originalFetch = globalThis.fetch; }); @@ -217,9 +218,8 @@ describe('request.requestAds', () => { expect(JSON.stringify(rejectionCall)).not.toContain('[object Object]'); }); - it('does not blank the slot when a later bid for the same slot is rejected', async () => { - // Regression: multi-bid scenario where a rejected bid must not erase an earlier - // successful render into the same slot. + it('rejects an ambiguous multi-winner response without blanking the slot', async () => { + // A final auction response must contain at most one winner per requested slot. const goodCreative = '
Safe Ad
'; (globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, @@ -243,16 +243,14 @@ describe('request.requestAds', () => { const { addAdUnits } = await import('../../src/core/registry'); const { requestAds } = await import('../../src/core/request'); - document.body.innerHTML = '
'; + document.body.innerHTML = '
existing
'; addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); await flushRequestAds(); - // The good creative should have rendered; the bad one should not have blanked it. - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(goodCreative); + expect(document.querySelector('#slot1 iframe')).toBeNull(); + expect(document.querySelector('#slot1')?.textContent).toContain('existing'); }); it('rejects creatives that sanitize to empty markup', async () => { @@ -296,6 +294,134 @@ describe('request.requestAds', () => { ); }); + it('keeps the latest direct owner when overlapping responses resolve out of order', async () => { + const resolves: Array<(response: Response) => void> = []; + (globalThis as any).fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolves.push(resolve); + }) + ); + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2), + } as any; + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + document.body.innerHTML = '
existing
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + requestAds(); + expect(resolves).toHaveLength(2); + const response = (creative: string) => + ({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + seatbid: [{ seat: 'trusted-server', bid: [{ impid: 'slot1', adm: creative }] }], + }), + }) as Response; + + resolves[1](response('
new owner
')); + await flushRequestAds(); + resolves[0](response('
stale owner
')); + await flushRequestAds(); + + const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement; + expect(iframe.srcdoc).toContain('new owner'); + expect(iframe.srcdoc).not.toContain('stale owner'); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'direct_render_rejected', + generation: 1, + reason: 'direct_owner_replaced', + }) + ); + }); + + it('records an exact direct auction winner, placement, and iframe load', async () => { + const auctionTraceId = '550e8400-e29b-41d4-a716-446655440000'; + const bidTraceId = '123e4567-e89b-42d3-a456-426614174000'; + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValue(1), + } as any; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: auctionTraceId, + source: 'auction_api', + outcome: 'completed', + }, + }, + }, + seatbid: [ + { + seat: 'trusted-server', + bid: [ + { + impid: 'slot1', + adm: '
direct
', + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: auctionTraceId, + bid_trace_id: bidTraceId, + source: 'auction_api', + slot_id: 'slot1', + provider: 'prebid', + bidder: 'example', + }, + }, + }, + }, + ], + }, + ], + }), + }); + + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'ts_winner_observed', + generation: 1, + auctionTraceId, + bidTraceId, + }) + ); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'pb_render_served', reason: 'direct_iframe_created' }) + ); + + const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement; + iframe.dispatchEvent(new Event('load')); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_load_acknowledged', + generation: 1, + reason: 'direct_iframe_load', + }) + ); + }); + it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML (globalThis as any).fetch = vi.fn().mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts new file mode 100644 index 000000000..a3eaa7d6f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('ad_trace integration gate', () => { + beforeEach(() => { + vi.resetModules(); + document.getElementById('ts-ad-trace-overlay')?.remove(); + delete window.__tsjs_adTraceActive; + delete window.tsjs; + }); + + afterEach(() => { + document.getElementById('ts-ad-trace-overlay')?.remove(); + delete window.__tsjs_adTraceActive; + delete window.tsjs; + }); + + it('leaves API and private recorders absent without the server bootstrap', async () => { + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(false); + expect(window.tsjs?.adTrace).toBeUndefined(); + expect(window.tsjs?.recordAdTrace).toBeUndefined(); + }); + + it('installs one immutable API and consumes the exact bootstrap', async () => { + window.__tsjs_adTraceActive = true; + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(true); + expect(window.__tsjs_adTraceActive).toBeUndefined(); + expect(Object.isFrozen(window.tsjs?.adTrace)).toBe(true); + expect(typeof window.tsjs?.recordAdTrace).toBe('function'); + expect(document.querySelectorAll('#ts-ad-trace-overlay')).toHaveLength(1); + expect(installAdTrace()).toBe(true); + expect(document.querySelectorAll('#ts-ad-trace-overlay')).toHaveLength(1); + }); + + it('does not accept the legacy tester cookie without bootstrap', async () => { + document.cookie = 'ts-tester=true; Path=/'; + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts new file mode 100644 index 000000000..0a5aaa217 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { installAdTraceOverlay } from '../../../src/integrations/ad_trace/overlay'; +import type { AdTraceApi } from '../../../src/core/types'; + +function api(): AdTraceApi { + const slot = { + slotId: 'slot-a', + latestGeneration: 1, + generations: [], + stages: { + trustedServer: { outcome: 'won', confidence: 'definitive', reason: 'winner' }, + prebid: { outcome: 'not_run', confidence: 'definitive', reason: 'direct' }, + gam: { outcome: 'trusted_server_candidate', confidence: 'probable', reason: 'render' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + }, + } as const; + const renders = [ + { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'gam_only', + confidence: 'probable', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }, + ] as const; + return { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => renders as any, + export: () => ({ + version: 1, + slots: [slot as any], + events: [], + renders: renders as any, + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }), + }; +} + +describe('ad trace overlay lifecycle', () => { + afterEach(() => { + document.getElementById('ts-ad-trace-overlay')?.remove(); + document.getElementById('slot-prefix-rendered')?.remove(); + delete window.tsjs; + vi.restoreAllMocks(); + }); + + it('finds prefix slots, observes resize, and coalesces animation frames', () => { + const element = document.createElement('div'); + element.id = 'slot-prefix-rendered'; + const rect = vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + width: 300, + height: 250, + } as DOMRect); + document.body.appendChild(element); + const updateVisibility = vi.fn(); + window.tsjs = { + adSlots: [{ id: 'slot-a', div_id: 'slot-prefix' }], + getAdTraceElement: () => element, + updateAdTraceVisibility: updateVisibility, + } as any; + + const observe = vi.fn(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe = observe; + unobserve = vi.fn(); + disconnect = vi.fn(); + } + ); + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + let subscriber: (() => void) | undefined; + installAdTraceOverlay(api(), (listener) => { + subscriber = listener; + return vi.fn(); + }); + + expect(rect).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledWith(element); + expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'visible'); + expect(element.getAttribute('data-ts-trace-seq')).toBe('1'); + expect(element.getAttribute('data-ts-trace-outcome')).toBe('gam_only'); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + subscriber?.(); + expect(frames).toHaveLength(1); + frames.shift()?.(1); + expect(rect).toHaveBeenCalledTimes(2); + + const replacement = document.createElement('div'); + replacement.id = element.id; + element.replaceWith(replacement); + subscriber?.(); + frames.shift()?.(2); + expect(replacement.hasAttribute('data-ts-trace-seq')).toBe(false); + expect(element.hasAttribute('data-ts-trace-seq')).toBe(false); + expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'disconnected'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 4a6368768..24f900123 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -883,15 +883,30 @@ describe('installTsRenderBridge', () => { afterEach(() => { vi.unstubAllGlobals(); document.getElementById('div-header')?.remove(); + document.getElementById('div-sidebar')?.remove(); delete (window as TestWindow).tsjs; }); + function capturePrivateOwner(slotId: string, divId: string): void { + const ts = (window as TestWindow).tsjs!; + const bid = ts.bids?.[slotId]; + ts.captureAdTraceRequest?.( + { + getSlotElementId: () => divId, + getTargeting: () => [], + }, + 'test_request', + { slotId, adId: bid?.hb_adid, bid } + ); + } + function createTrustedSlotIframe(): Window { const slot = document.createElement('div'); slot.id = 'div-header'; const iframe = document.createElement('iframe'); slot.appendChild(iframe); document.body.appendChild(slot); + capturePrivateOwner('homepage_header', slot.id); return iframe.contentWindow!; } @@ -960,7 +975,7 @@ describe('installTsRenderBridge', () => { expect(fetchStub).toHaveBeenCalledWith( 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } + expect.objectContaining({ mode: 'cors', signal: expect.any(AbortSignal) }) ); expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); @@ -982,17 +997,15 @@ describe('installTsRenderBridge', () => { }) as unknown as MessageEvent ); await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchStub).toHaveBeenCalledTimes(1); + expect(portMessages).toHaveLength(1); expect(beaconSpy).toHaveBeenCalledTimes(2); beaconSpy.mockRestore(); }); it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { - // Concurrent render double-fire guard: two 'Prebid Request' messages for the - // same adId can arrive before the first cache fetch settles. The in-flight - // `renderingAdIds` gate must collapse them to a single fetch — the persistent - // firedBeacons dedup only engages after a fetch resolves, so it cannot stop - // the second fetch on its own. Deferring the fetch keeps both messages in the - // window where only the in-flight gate can prevent the duplicate. + // Two duplicate requests from the exact same private owner collapse to one + // fetch while it remains current. const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const mockAd = '
Test Creative
'; let resolveFetch: (value: Response) => void = () => {}; @@ -1039,6 +1052,190 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('supersedes a same-adId cache owner from a different source', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const resolves: Array<(value: Response) => void> = []; + fetchStub.mockImplementation( + () => + new Promise((resolve) => { + resolves.push(resolve); + }) + ); + const bridgeListener = await captureBridgeListener(); + const oldPort = { postMessage: vi.fn() }; + const newPort = { postMessage: vi.fn() }; + const oldSource = createTrustedSlotIframe(); + const newFrame = document.createElement('iframe'); + document.getElementById('div-header')?.appendChild(newFrame); + const newSource = newFrame.contentWindow!; + + const dispatch = (source: Window, port: { postMessage: ReturnType }): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + dispatch(oldSource, oldPort); + dispatch(newSource, newPort); + expect(fetchStub).toHaveBeenCalledTimes(2); + + resolves[0]({ ok: true, text: () => Promise.resolve('
old
') } as Response); + resolves[1]({ ok: true, text: () => Promise.resolve('
new
') } as Response); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(oldPort.postMessage).not.toHaveBeenCalled(); + expect(newPort.postMessage).toHaveBeenCalledTimes(1); + expect(beaconSpy).toHaveBeenCalledTimes(2); + beaconSpy.mockRestore(); + }); + + it('allows concurrent same-adId owners in different slots', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const ts = (window as TestWindow).tsjs!; + ts.bids!.sidebar = { + ...ts.bids!.homepage_header, + nurl: 'https://ssp.example/sidebar-win', + burl: 'https://ssp.example/sidebar-bill', + }; + ts.adSlots!.push({ + id: 'sidebar', + formats: [[300, 250]], + gam_unit_path: '/a/b/sidebar', + div_id: 'div-sidebar', + targeting: {}, + }); + const resolves: Array<(value: Response) => void> = []; + fetchStub.mockImplementation( + () => + new Promise((resolve) => { + resolves.push(resolve); + }) + ); + const bridgeListener = await captureBridgeListener(); + const headerPort = { postMessage: vi.fn() }; + const sidebarPort = { postMessage: vi.fn() }; + const headerSource = createTrustedSlotIframe(); + const sidebar = document.createElement('div'); + sidebar.id = 'div-sidebar'; + const sidebarFrame = document.createElement('iframe'); + sidebar.appendChild(sidebarFrame); + document.body.appendChild(sidebar); + capturePrivateOwner('sidebar', sidebar.id); + + const dispatch = (source: Window, port: { postMessage: ReturnType }): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + dispatch(headerSource, headerPort); + dispatch(sidebarFrame.contentWindow!, sidebarPort); + resolves.forEach((resolve, index) => + resolve({ + ok: true, + text: () => Promise.resolve(`
creative ${index}
`), + } as Response) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(headerPort.postMessage).toHaveBeenCalledTimes(1); + expect(sidebarPort.postMessage).toHaveBeenCalledTimes(1); + expect(beaconSpy).toHaveBeenCalledTimes(4); + beaconSpy.mockRestore(); + }); + + it('blocks a late TS message after navigation before page-bids applies', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const port = { postMessage: vi.fn() }; + const stop = vi.fn(); + + window.dispatchEvent(new PopStateEvent('popstate')); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: stop, + }) as unknown as MessageEvent + ); + + expect(stop).toHaveBeenCalledOnce(); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(fetchStub).not.toHaveBeenCalled(); + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + + it('blocks an old traced message after a newer request capture', async () => { + const ts = (window as TestWindow).tsjs!; + ts.recordAdTrace = vi.fn(); + ts.nextAdTraceGeneration = vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2); + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + ts.bids!.homepage_header = { + ...ts.bids!.homepage_header, + hb_adid: 'new-cache-uuid', + }; + capturePrivateOwner('homepage_header', 'div-header'); + const port = { postMessage: vi.fn() }; + const stop = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: stop, + }) as unknown as MessageEvent + ); + + expect(stop).toHaveBeenCalledOnce(); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('drops a detached stale cache completion without responding or billing', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let resolveFetch: (value: Response) => void = () => {}; + fetchStub.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const bridgeListener = await captureBridgeListener(); + const port = { postMessage: vi.fn() }; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + document.getElementById('div-header')?.remove(); + resolveFetch({ + ok: true, + text: () => Promise.resolve('
stale
'), + } as Response); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const debugAdm = '
Debug Creative
'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts new file mode 100644 index 000000000..50d051eaf --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const OLD_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; +const NEW_TOKEN = '650e8400-e29b-41d4-a716-446655440000'; + +function slotWithTargeting(values: Record) { + return { + getSlotElementId: () => 'div-header', + getTargeting: (key: string) => (values[key] ? [values[key]] : []), + }; +} + +function trustedSource(): Window { + const root = document.createElement('div'); + root.id = 'div-header'; + const iframe = document.createElement('iframe'); + root.appendChild(iframe); + document.body.appendChild(root); + return iframe.contentWindow!; +} + +describe('GPT immutable ad trace render attribution', () => { + let bridge: (event: MessageEvent) => void; + let module: typeof import('../../../src/integrations/gpt/index'); + let record: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + record = vi.fn(); + Object.defineProperty(navigator, 'sendBeacon', { + value: vi.fn(), + configurable: true, + writable: true, + }); + let generation = 0; + window.tsjs = { + recordAdTrace: record, + nextAdTraceGeneration: () => ++generation, + divToSlotId: { 'div-header': 'slot-a' }, + adSlots: [ + { + id: 'slot-a', + div_id: 'div-header', + gam_unit_path: '/123/example', + formats: [[300, 250]], + }, + ], + bids: { + 'slot-a': { + hb_adid: 'old-ad-id', + adm: '
Old creative
', + nurl: 'https://billing.example/win', + burl: 'https://billing.example/bill', + trace: { + version: 1, + auctionTraceId: '750e8400-e29b-41d4-a716-446655440000', + bidTraceId: OLD_TOKEN, + source: 'initial_navigation', + slotId: 'slot-a', + provider: 'prebid', + bidder: 'example-bidder', + }, + }, + }, + } as any; + const originalAdd = window.addEventListener.bind(window); + const spy = vi + .spyOn(window, 'addEventListener') + .mockImplementation((type, listener, options) => { + if (type === 'message') bridge = listener as (event: MessageEvent) => void; + originalAdd(type, listener, options); + }); + module = await import('../../../src/integrations/gpt/index'); + spy.mockRestore(); + }); + + afterEach(() => { + document.getElementById('div-header')?.remove(); + delete window.tsjs; + vi.restoreAllMocks(); + }); + + it('preserves authoritative missing values in a queued boundary snapshot', () => { + const source = trustedSource(); + const port = { postMessage: vi.fn() }; + const stop = vi.fn(); + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest(slotWithTargeting({ hb_adid: 'old-ad-id' }) as any, 'bootstrap', { + slotId: 'slot-a', + bidder: undefined, + adId: undefined, + traceToken: undefined, + bid: undefined, + }); + + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: stop, + }) as unknown as MessageEvent + ); + + expect(stop).toHaveBeenCalledOnce(); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(beacon).not.toHaveBeenCalled(); + }); + + it('never pairs a new client or refreshed TS adId with the stale live bid payload', () => { + const source = trustedSource(); + const port = { postMessage: vi.fn() }; + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + + for (const targeting of [ + { hb_adid: 'client-ad-id', hb_bidder: 'client-bidder' }, + { hb_adid: 'new-ts-ad-id', hb_bidder: 'example-bidder', ts_trace: NEW_TOKEN }, + ]) { + window.tsjs!.prebidCorrelation = [ + { + auctionId: 'auction-2', + slotId: 'slot-a', + requestId: 'request-2', + adId: targeting.hb_adid, + bidder: targeting.hb_bidder, + ...(targeting.ts_trace ? { traceToken: targeting.ts_trace } : {}), + ...(!targeting.ts_trace ? { events: ['prebid_bid_won' as const] } : {}), + }, + ]; + module.captureAdTraceRequest(slotWithTargeting(targeting) as any, 'prebid_refresh'); + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: targeting.hb_adid }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + } + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(beacon).not.toHaveBeenCalled(); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_targeting_selected', outcome: 'client_bid_won' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_bid_won', generation: 1 }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'prebid_targeting_selected', + outcome: 'won', + bidTraceId: NEW_TOKEN, + }) + ); + + window.tsjs!.prebidCorrelation = [ + { + auctionId: 'auction-3', + slotId: 'slot-a', + requestId: 'client-request', + adId: 'winning-client-ad', + bidder: 'client-bidder', + }, + { + auctionId: 'auction-3', + slotId: 'slot-a', + requestId: 'ts-request', + adId: 'losing-ts-ad', + bidder: 'trustedServer', + traceToken: NEW_TOKEN, + }, + ]; + module.captureAdTraceRequest( + slotWithTargeting({ hb_adid: 'winning-client-ad', hb_bidder: 'client-bidder' }) as any, + 'prebid_refresh' + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_targeting_selected', outcome: 'lost' }) + ); + }); + + it('serves, bills once, and acknowledges only the exact immutable generation/source/token', () => { + const source = trustedSource(); + const foreignSource = window; + const port = { postMessage: vi.fn() }; + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest( + slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }) as any, + 'display' + ); + + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + const response = JSON.parse(port.postMessage.mock.calls[0][0]); + expect(response.traceToken).toBe(OLD_TOKEN); + expect(response.ad).toBe('
Old creative
'); + expect(beacon).toHaveBeenCalledTimes(2); + + // A newer generation does not steal or invalidate the retained exact ack. + const nextSlot = slotWithTargeting({ hb_adid: 'client-next', hb_bidder: 'client-bidder' }); + module.captureAdTraceRequest(nextSlot as any, 'prebid_refresh'); + + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source: foreignSource, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: NEW_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_load_acknowledged', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + expect(beacon).toHaveBeenCalledTimes(2); + + module.supersedeAdTraceSlot(nextSlot as any, 'slot_destroyed'); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'generation_superseded', + generation: 2, + reason: 'slot_destroyed', + }) + ); + }); + + it('rejects acknowledgements after the exact slot generation is superseded', () => { + const source = trustedSource(); + const slot = slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }); + const port = { postMessage: vi.fn() }; + vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest(slot as any, 'display'); + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + module.supersedeAdTraceSlot(slot as any, 'slot_destroyed'); + record.mockClear(); + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'pb_render_rejected', reason: 'invalid_acknowledgement' }) + ); + }); + + it('expires pending acknowledgements after thirty seconds', () => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const source = trustedSource(); + const slot = slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }); + const port = { postMessage: vi.fn() }; + vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest(slot as any, 'display'); + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + now = 30_001; + record.mockClear(); + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'generation_superseded', reason: 'ack_expired' }) + ); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index 406c6d1f5..2f21da832 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -240,6 +240,14 @@ describe('GPT – installTsAdInit', () => { ['ts_initial', ['1']], ['pos', ['old-pos']], ]); + const clearTargeting = vi.fn((key?: string) => { + if (key) { + slotTargeting.delete(key); + } else { + slotTargeting.clear(); + } + return gptSlot; + }); const gptSlot: any = { getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), @@ -247,14 +255,7 @@ describe('GPT – installTsAdInit', () => { slotTargeting.set(key, Array.isArray(value) ? value : [value]); return gptSlot; }), - clearTargeting: vi.fn((key?: string) => { - if (key) { - slotTargeting.delete(key); - } else { - slotTargeting.clear(); - } - return gptSlot; - }), + clearTargeting, }; const pubads = { getSlots: vi.fn(() => [gptSlot]), @@ -295,13 +296,13 @@ describe('GPT – installTsAdInit', () => { installTsAdInit(); (window as any).tsjs.adInit(); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('pos'); + expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); + expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(clearTargeting).toHaveBeenCalledWith('pos'); expect(slotTargeting.get('hb_pb')).toBeUndefined(); expect(slotTargeting.get('hb_bidder')).toBeUndefined(); expect(slotTargeting.get('hb_adid')).toBeUndefined(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 726f40b49..f0a878bfb 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -8,6 +8,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -21,6 +22,7 @@ const { () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); + const mockOnEvent = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, @@ -28,6 +30,7 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, + onEvent: mockOnEvent, adUnits: [] as any[], }; const mockAdapterManager = { @@ -40,6 +43,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -149,6 +153,39 @@ describe('prebid/auctionBidsToPrebidBids', () => { }); }); + it('adds adapter targeting only for a validated Trusted Server trace', () => { + const traced: AuctionBid = { + impid: 'slot-traced', + adm: '
Ad
', + price: 2, + width: 300, + height: 250, + seat: 'example-bidder', + creativeId: 'creative-1', + adomain: [], + trace: { + version: 1, + auctionTraceId: '650e8400-e29b-41d4-a716-446655440000', + bidTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + slotId: 'slot-traced', + provider: 'prebid', + bidder: 'example-bidder', + }, + }; + + const [bid] = auctionBidsToPrebidBids( + [traced], + [{ adUnitCode: 'slot-traced', bidId: 'request-1' }] + ); + expect(bid.adserverTargeting).toEqual({ + ts_trace: '550e8400-e29b-41d4-a716-446655440000', + }); + expect(auctionBidsToPrebidBids([{ ...traced, trace: undefined }], [])[0]).not.toHaveProperty( + 'adserverTargeting' + ); + }); + it('falls back to impid when no matching bidRequest found', () => { const auctionBids: AuctionBid[] = [ { @@ -218,6 +255,8 @@ describe('prebid/installPrebidNpm', () => { document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete (window as any).__tsjs_prebid; delete (window as any).__tsjs_prebid_diagnostics; + delete (mockPbjs as any).__tsAdTraceObserved; + delete window.tsjs; }); afterEach(() => { @@ -800,6 +839,50 @@ describe('prebid/installPrebidNpm', () => { expect(document.cookie).toBe(''); }); + + it('joins late winner and render events to the retained selected generation', () => { + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + prebidSelectedParticipants: [ + { + auctionId: 'auction-1', + slotId: 'slot-a', + requestId: 'request-1', + adId: 'ad-1', + bidder: 'client-bidder', + generation: 7, + selectedAt: performance.now(), + }, + ], + } as any; + installPrebidNpm(); + const handlers = new Map) => void>( + mockOnEvent.mock.calls.map(([event, handler]) => [event, handler]) + ); + const bid = { + auctionId: 'auction-1', + adUnitCode: 'slot-a', + requestId: 'request-1', + adId: 'ad-1', + bidderCode: 'client-bidder', + }; + + handlers.get('bidWon')?.(bid); + handlers.get('adRenderSucceeded')?.({ bid }); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_bid_won', generation: 7, slotId: 'slot-a' }) + ); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'prebid_render_succeeded', + generation: 7, + slotId: 'slot-a', + }) + ); + expect(window.tsjs.prebidSelectedParticipants).toEqual([]); + }); }); }); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b975590c6..35384e13f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -998,6 +998,23 @@ apply when the integration section exists in `trusted-server.toml`. | --------- | ------- | ------------------------------ | | `enabled` | Boolean | Enable/disable the integration | +### Ad Trace Integration + +**Section**: `[integrations.ad_trace]` + +| Field | Type | Default | Description | +| --------- | ------- | ------- | ------------------------------------------------ | +| `enabled` | Boolean | `false` | Include tester-only auction trace browser support | + +Browser-visible auction IDs, bid IDs, targeting, API state, and the console require this setting plus an activated browser session. Visit a publisher page with the exact query `?ts_console=true` or `?ts_console=1`; Trusted Server enables the first response and sets a host-only session cookie automatically. Use `?ts_console=false` or `?ts_console=0` to clear the session. The reserved query is removed from downstream requests and cleaned from eligible HTML URLs. Active trace responses are private and non-storeable. + +The integration is disabled by default. The query is a self-service diagnostic toggle, not authorization, and does nothing without the explicit configuration gate. The console never exposes the internal auction request ID, identity data, consent strings, page URLs, partner notification URLs, cache coordinates, raw targeting, or creative markup. A creative marked `confirmed` means its exact Trusted Server renderer iframe load was acknowledged; it does not claim viewability or arbitrary advertiser JavaScript completion. + +```toml +[integrations.ad_trace] +enabled = false +``` + ### Prebid Integration **Section**: `[integrations.prebid]` diff --git a/scripts/generate-integration-viceroy-configs.sh b/scripts/generate-integration-viceroy-configs.sh index 761d06926..97ee870a0 100755 --- a/scripts/generate-integration-viceroy-configs.sh +++ b/scripts/generate-integration-viceroy-configs.sh @@ -13,6 +13,7 @@ ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/target/integration-test-artifacts}" CONFIG_DIR="$ARTIFACTS_DIR/configs" TEMPLATE_PATH="crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml" APP_CONFIG_PATH="crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml" +AD_TRACE_APP_CONFIG_PATH="crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml" INTEGRATION_TARGET_DIR="crates/trusted-server-integration-tests/target" ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" @@ -41,3 +42,9 @@ fi --app-config "$APP_CONFIG_PATH" \ --output "$CONFIG_DIR/viceroy.toml" \ --origin-url "$ORIGIN_URL" + +"$GENERATOR_BIN" \ + --template "$TEMPLATE_PATH" \ + --app-config "$AD_TRACE_APP_CONFIG_PATH" \ + --output "$CONFIG_DIR/viceroy-ad-trace.toml" \ + --origin-url "$ORIGIN_URL" diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index ce5e64387..0ec16089e 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -37,6 +37,19 @@ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" +GENERATED_AD_TRACE_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy-ad-trace.toml" + +# Build the actual external Prebid bundle consumed by the isolated ad-trace +# fixture. The browser routes its first-party managed URL to this local asset; +# no public ad network is contacted. +echo "==> Building deterministic external Prebid fixture bundle..." +rm -rf "$REPO_ROOT/target/integration-test-artifacts/prebid" +mkdir -p "$REPO_ROOT/target/integration-test-artifacts/prebid" +npm ci --prefix crates/trusted-server-js/lib +npm run --prefix crates/trusted-server-js/lib build:prebid-external -- \ + --adapters=rubicon \ + --user-id-modules=sharedIdSystem \ + --out "$REPO_ROOT/target/integration-test-artifacts/prebid" # --- Build Docker images --- echo "==> Building WordPress test container..." @@ -49,6 +62,12 @@ docker build \ -t test-nextjs:latest \ crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ +echo "==> Building ad-trace test container..." +docker build \ + -f crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile \ + -t test-ad-trace:latest \ + . + # --- Install Playwright --- echo "==> Installing Playwright dependencies..." cd "$REPO_ROOT/$BROWSER_DIR" @@ -71,15 +90,22 @@ stop_matching_containers() { } cleanup() { + stop_matching_containers test-ad-trace:latest stop_matching_containers test-nextjs:latest stop_matching_containers test-wordpress:latest } trap cleanup EXIT # --- Run tests for each framework --- -for framework in nextjs wordpress; do +for framework in nextjs wordpress ad-trace; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test "$@" + if [ "$framework" = "ad-trace" ]; then + TEST_FRAMEWORK="$framework" VICEROY_CONFIG_PATH="$GENERATED_AD_TRACE_CONFIG_PATH" \ + npx playwright test "$@" + else + TEST_FRAMEWORK="$framework" VICEROY_CONFIG_PATH="$GENERATED_VICEROY_CONFIG_PATH" \ + npx playwright test "$@" + fi done echo "==> All browser tests passed." diff --git a/tinybird/datasources/auction_events_raw.datasource b/tinybird/datasources/auction_events_raw.datasource index d62f8ae5d..592158713 100644 --- a/tinybird/datasources/auction_events_raw.datasource +++ b/tinybird/datasources/auction_events_raw.datasource @@ -32,6 +32,7 @@ SCHEMA > `price_cpm` Nullable(Float64), `currency` LowCardinality(Nullable(String)), `is_win` Nullable(UInt8), + `bid_trace_id` Nullable(UUID), `ad_domain` Nullable(String), `ad_id` Nullable(String), `event_date` Date DEFAULT toDate(event_ts) diff --git a/tinybird/fixtures/auction_events_raw.ndjson b/tinybird/fixtures/auction_events_raw.ndjson index 078d0c533..10626e3ad 100644 --- a/tinybird/fixtures/auction_events_raw.ndjson +++ b/tinybird/fixtures/auction_events_raw.ndjson @@ -1,7 +1,7 @@ {"event_ts":"2026-06-23 12:00:00.000","event_kind":"summary","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":"completed","terminal_reason":null,"slot_count":2,"total_time_ms":120,"winning_bid_count":1,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:00:00.000","event_kind":"provider_call","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":"bidder","status":"success","provider_response_time_ms":80,"provider_bid_count":2,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:00:00.000","event_kind":"provider_call","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"aps","provider_role":"bidder","status":"nobid","provider_response_time_ms":95,"provider_bid_count":0,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} -{"event_ts":"2026-06-23 12:00:00.000","event_kind":"bid","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":"slot-1","slot_w":300,"slot_h":250,"media_type":"banner","seat":"kargo","price_cpm":1.25,"currency":"USD","is_win":1,"ad_domain":"advertiser.example","ad_id":"ad-1"} +{"event_ts":"2026-06-23 12:00:00.000","event_kind":"bid","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":"slot-1","slot_w":300,"slot_h":250,"media_type":"banner","seat":"kargo","price_cpm":1.25,"currency":"USD","is_win":1,"bid_trace_id":"950e8400-e29b-41d4-a716-446655440000","ad_domain":"advertiser.example","ad_id":"ad-1"} {"event_ts":"2026-06-23 12:01:00.000","event_kind":"summary","auction_id":"650e8400-e29b-41d4-a716-446655440000","auction_source":"initial_navigation","publisher_domain":"test-publisher.example","page_path":"/sports","country":"US","region":"CA","is_mobile":1,"is_known_browser":1,"gdpr_applies":0,"consent_present":1,"terminal_status":"abandoned","terminal_reason":"pass_through_response","slot_count":1,"total_time_ms":35,"winning_bid_count":0,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:01:00.000","event_kind":"provider_call","auction_id":"650e8400-e29b-41d4-a716-446655440000","auction_source":"initial_navigation","publisher_domain":"test-publisher.example","page_path":"/sports","country":"US","region":"CA","is_mobile":1,"is_known_browser":1,"gdpr_applies":0,"consent_present":1,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":"bidder","status":"abandoned","provider_response_time_ms":35,"provider_bid_count":0,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:02:00.000","event_kind":"summary","auction_id":"750e8400-e29b-41d4-a716-446655440000","auction_source":"spa_navigation","publisher_domain":"test-publisher.example","page_path":"/privacy","country":"DE","region":null,"is_mobile":2,"is_known_browser":2,"gdpr_applies":1,"consent_present":1,"terminal_status":"skipped","terminal_reason":"consent_denied","slot_count":1,"total_time_ms":0,"winning_bid_count":0,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 26d95d681..c1ae6ddb0 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -59,6 +59,11 @@ enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] max_combined_payload_bytes = 10485760 +# Session-scoped auction-to-creative trace diagnostics. When enabled, visit a +# publisher page with `?ts_console=1` or `?ts_console=true` to open the console. +[integrations.ad_trace] +enabled = false + [integrations.testlight] enabled = false endpoint = "https://testlight.example.com/openrtb2/auction" From 3df591fd9dee9803e885171b78322df671c9ff5c Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 12:06:46 -0500 Subject: [PATCH 103/494] Revert "Merge pull request #922 from IABTechLab/trace-auction-winners-to-creatives" This reverts commit 58706b49e2200c34b8071132f3aa598aec537364, reversing changes made to 96fec883eb8e0899ba4e5604932b801edaf7f8af. --- .../src/auction/formats.rs | 34 ------ crates/trusted-server-core/src/auction/mod.rs | 1 - .../src/auction/orchestrator.rs | 28 ----- .../trusted-server-core/src/auction/types.rs | 79 ------------ .../src/integrations/prebid.rs | 43 ------- crates/trusted-server-core/src/publisher.rs | 79 ++---------- .../trusted-server-js/lib/src/core/auction.ts | 12 -- .../trusted-server-js/lib/src/core/request.ts | 37 ------ .../trusted-server-js/lib/src/core/trace.ts | 71 ----------- .../trusted-server-js/lib/src/core/types.ts | 43 ------- .../lib/src/integrations/gpt/index.ts | 56 +-------- .../lib/test/core/auction.test.ts | 34 ------ .../lib/test/core/request.test.ts | 107 ---------------- .../lib/test/core/trace.test.ts | 114 ------------------ .../lib/test/integrations/gpt/ad_init.test.ts | 86 ------------- 15 files changed, 8 insertions(+), 816 deletions(-) delete mode 100644 crates/trusted-server-js/lib/src/core/trace.ts delete mode 100644 crates/trusted-server-js/lib/test/core/trace.test.ts diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 2754861d1..284db6242 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -14,7 +14,6 @@ use url::Url; use uuid::Uuid; use crate::auction::context::ContextValue; -use crate::auction::types::adm_trace_hash; use crate::consent::ConsentContext; use crate::constants::{HEADER_X_TS_EC_CONSENT, HEADER_X_TS_EIDS, HEADER_X_TS_EIDS_TRUNCATED}; use crate::creative; @@ -342,39 +341,6 @@ pub fn convert_to_openrtb_response( })); }; - // Trace hash over the exact markup delivered to the client (post - // sanitize/rewrite) so the client can stamp the rendered creative with - // a value that matches this response byte-for-byte. Logged at info so - // server logs join against the DOM markers without debug logging. - let adm_hash = adm - .as_deref() - .filter(|markup| !markup.is_empty()) - .map(adm_trace_hash); - if let Some(ref hash) = adm_hash { - log::info!( - "auction delivered creative: auction_id={} slot_id={} bidder={} crid={:?} adm_hash={}", - auction_request.id, - slot_id, - bid.bidder, - bid.creative_id, - hash, - ); - } - let mut ts_ext = serde_json::Map::new(); - ts_ext.insert( - "auction_id".to_string(), - serde_json::Value::String(auction_request.id.clone()), - ); - if let Some(ref hash) = adm_hash { - ts_ext.insert( - "adm_hash".to_string(), - serde_json::Value::String(hash.clone()), - ); - } - let mut ext = ext.unwrap_or_default(); - ext.insert("ts".to_string(), JsonValue::Object(ts_ext)); - let ext = Some(ext); - let openrtb_bid = OpenRtbBid { id: bid .bid_id diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index ea39d8739..986beb984 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -35,7 +35,6 @@ pub use telemetry::{ }; pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, - adm_trace_hash, }; /// Type alias for provider builder functions. diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9a17553c9..eb9b1d138 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -166,29 +166,6 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } -/// Log one structured trace line per winning bid. -/// -/// Emits the full trace tuple — auction ID, slot, bidder, ad/cache/creative -/// IDs, and the creative trace hash — so a rendered creative on the page -/// (carrying the same tuple in its DOM markers) can be joined back to this -/// auction in server logs. -fn log_winning_bids(auction_id: &str, winning_bids: &HashMap) { - for (slot_id, bid) in winning_bids { - log::info!( - "auction winner: auction_id={} slot_id={} bidder={} price={:?} bid_id={:?} ad_id={:?} cache_id={:?} crid={:?} adm_hash={:?}", - auction_id, - slot_id, - bid.bidder, - bid.price, - bid.bid_id, - bid.ad_id, - bid.cache_id, - bid.creative_id, - bid.creative_trace_hash(), - ); - } -} - fn snapshot_context_request(request: &Request) -> Request { let mut snapshot = Request::new(EdgeBody::empty()); *snapshot.method_mut() = request.method().clone(); @@ -303,8 +280,6 @@ impl AuctionOrchestrator { strategy_name ); - log_winning_bids(&request.id, &result.winning_bids); - Ok(OrchestrationResult { total_time_ms: start_time.elapsed().as_millis() as u64, ..result @@ -1325,7 +1300,6 @@ impl AuctionOrchestrator { responses.len(), ); let winning = self.select_winning_bids(&responses, &floor_prices); - log_winning_bids(&request.id, &winning); return OrchestrationResult { provider_responses: responses, mediator_response: None, @@ -1443,8 +1417,6 @@ impl AuctionOrchestrator { (None, self.select_winning_bids(&responses, &floor_prices)) }; - log_winning_bids(&request.id, &winning_bids); - OrchestrationResult { provider_responses: responses, mediator_response, diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 31f4a6341..a6ad61f3a 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -313,49 +313,6 @@ impl From<&AuctionResponse> for ProviderSummary { } } -/// Length of the hex-encoded creative trace hash. -/// -/// 16 hex chars (64 bits of SHA-256) — short enough for a DOM attribute and a -/// log field, long enough that collisions across a page's creatives are not a -/// practical concern for tracing. -const ADM_TRACE_HASH_LEN: usize = 16; - -/// Compute the trace hash for a creative markup string. -/// -/// The hash is the first [`ADM_TRACE_HASH_LEN`] hex characters of the SHA-256 -/// of the exact bytes handed to the client. It is a correlation key for -/// tracing a winning bid to the creative rendered on the page — server logs, -/// the injected bid payload, and DOM markers all carry the same value — not an -/// integrity mechanism. -/// -/// # Examples -/// -/// ``` -/// use trusted_server_core::auction::adm_trace_hash; -/// -/// let hash = adm_trace_hash("
example creative
"); -/// assert_eq!(hash.len(), 16); -/// ``` -#[must_use] -pub fn adm_trace_hash(adm: &str) -> String { - use sha2::{Digest as _, Sha256}; - - let digest = Sha256::digest(adm.as_bytes()); - let mut hex = hex::encode(digest); - hex.truncate(ADM_TRACE_HASH_LEN); - hex -} - -impl Bid { - /// Trace hash of this bid's creative markup, when present. - /// - /// See [`adm_trace_hash`] for the hash definition. - #[must_use] - pub fn creative_trace_hash(&self) -> Option { - self.creative.as_deref().map(adm_trace_hash) - } -} - /// `OpenRTB` response metadata for the orchestrator. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrchestratorExt { @@ -451,42 +408,6 @@ mod tests { } } - #[test] - fn adm_trace_hash_is_sha256_prefix() { - // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad - assert_eq!( - adm_trace_hash("abc"), - "ba7816bf8f01cfea", - "should be the first 16 hex chars of the SHA-256 digest" - ); - } - - #[test] - fn adm_trace_hash_distinguishes_creatives() { - assert_ne!( - adm_trace_hash("
creative a
"), - adm_trace_hash("
creative b
"), - "should produce different hashes for different markup" - ); - } - - #[test] - fn creative_trace_hash_follows_creative_presence() { - let mut bid = make_bid("kargo"); - assert_eq!( - bid.creative_trace_hash(), - None, - "should be None without creative markup" - ); - - bid.creative = Some("
example creative
".to_owned()); - assert_eq!( - bid.creative_trace_hash(), - Some(adm_trace_hash("
example creative
")), - "should hash the creative markup when present" - ); - } - #[test] fn provider_summary_from_successful_response() { let response = AuctionResponse::success( diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index e50717fb9..1d17bd0a2 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -6844,49 +6844,6 @@ set = { networkId = 42 } ); } - #[test] - fn parse_bid_extracts_crid() { - let bid_json = serde_json::json!({ - "id": "bid-id-321", - "impid": "atf_sidebar_ad", - "price": 1.25, - "adm": "
ad
", - "crid": "cr-98765", - "w": 300, - "h": 250 - }); - let provider = PrebidAuctionProvider::new(base_config()); - let bid = provider - .parse_bid(&bid_json, "kargo") - .expect("should parse bid"); - assert_eq!( - bid.creative_id.as_deref(), - Some("cr-98765"), - "should extract the OpenRTB creative ID" - ); - assert_eq!( - bid.bid_id.as_deref(), - Some("bid-id-321"), - "should extract the OpenRTB bid ID" - ); - } - - #[test] - fn parse_bid_sets_crid_to_none_when_absent() { - let bid_json = serde_json::json!({ - "id": "bid-id-322", - "impid": "atf_sidebar_ad", - "price": 1.25, - "w": 300, - "h": 250 - }); - let provider = PrebidAuctionProvider::new(base_config()); - let bid = provider - .parse_bid(&bid_json, "kargo") - .expect("should parse bid"); - assert!(bid.creative_id.is_none(), "should be None when crid absent"); - } - #[test] fn parse_bid_sets_cache_fields_to_none_when_no_cache_entry() { let bid_json = serde_json::json!({ diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ba35a8818..699dcbc97 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1550,7 +1550,6 @@ pub async fn stream_publisher_body_async( ) .await; } - return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -1709,9 +1708,6 @@ fn request_origin(scheme: &str, host: &str) -> String { } /// Write winning bids from an auction result into the shared `ad_bids_state` lock. -/// -/// `auction_id` propagates into each bid entry as `hb_auction_id` so the -/// injected `tsjs.bids` payload can be traced back to the server-side auction. pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, @@ -1719,7 +1715,6 @@ pub(crate) fn write_bids_to_state( settings: &Settings, request_origin: &str, include_debug_bid: bool, - auction_id: Option<&str>, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", @@ -1732,7 +1727,6 @@ pub(crate) fn write_bids_to_state( settings, request_origin, include_debug_bid, - auction_id, ); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); @@ -2374,10 +2368,6 @@ async fn collect_non_html_auction( settings, &request_origin(¶ms.request_scheme, ¶ms.request_host), settings.debug.inject_adm_for_testing, - telemetry - .auction_request - .as_ref() - .map(|request| request.id.as_str()), ); } @@ -2426,7 +2416,6 @@ async fn collect_stream_auction( settings, request_origin, settings.debug.inject_adm_for_testing, - telemetry.auction_request.as_ref().map(|r| r.id.as_str()), ); if settings.debug.auction_html_comment { @@ -3170,19 +3159,12 @@ fn html_escape_for_script(s: &str) -> String { /// /// Returns a JSON object map of slot ID → bid metadata including the bucketed /// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. -/// -/// Every entry also carries the trace fields `hb_auction_id` (when -/// `auction_id` is known), `hb_crid` (when the bidder returned a creative ID), -/// and `hb_adm_hash` (when the bid has creative markup) so the client can -/// stamp rendered creatives with a tuple that joins back to the server-side -/// `auction winner:` log lines. pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, settings: &Settings, request_origin: &str, include_debug_bid: bool, - auction_id: Option<&str>, ) -> serde_json::Map { // Inline creatives render in a foreign origin (PUC's srcdoc under GAM), so // their proxy/click URLs must be absolute against the origin the visitor is @@ -3205,24 +3187,6 @@ pub(crate) fn build_bid_map( "hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone()), ); - if let Some(auction_id) = auction_id { - obj.insert( - "hb_auction_id".to_string(), - serde_json::Value::String(auction_id.to_string()), - ); - } - if let Some(creative_id) = &bid.creative_id { - obj.insert( - "hb_crid".to_string(), - serde_json::Value::String(creative_id.clone()), - ); - } - if let Some(adm_hash) = bid.creative_trace_hash() { - obj.insert( - "hb_adm_hash".to_string(), - serde_json::Value::String(adm_hash), - ); - } // Winning creative dimensions — the bridge sizes the inline // render from these, falling back to the first configured slot // format only when absent, which mis-sizes a multi-size slot. @@ -3653,8 +3617,8 @@ pub async fn handle_page_bids( // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; - let (winning_bids, page_auction_id) = if matched_slots.is_empty() { - (std::collections::HashMap::new(), None) + let winning_bids = if matched_slots.is_empty() { + std::collections::HashMap::new() } else { // Same publisher identity as the outbound bid request — see the // matching note on the initial-navigation observation above. @@ -3720,7 +3684,7 @@ pub async fn handle_page_bids( ) }) .await; - (winning_bids, Some(auction_request.id.clone())) + winning_bids } Err(e) => { log::warn!("page-bids auction failed: {e:?}"); @@ -3737,7 +3701,7 @@ pub async fn handle_page_bids( ) }) .await; - (std::collections::HashMap::new(), None) + std::collections::HashMap::new() } } } else { @@ -3763,7 +3727,7 @@ pub async fn handle_page_bids( ) }) .await; - (std::collections::HashMap::new(), None) + std::collections::HashMap::new() } }; @@ -3773,7 +3737,6 @@ pub async fn handle_page_bids( settings, &page_bids_request_origin, settings.debug.inject_adm_for_testing, - page_auction_id.as_deref(), ); // Gate slots on the ad-stack kill switch / consent: when disabled, return no @@ -7476,7 +7439,6 @@ mod tests { &test_settings(), "", false, - None, ); let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); let obj = entry.as_object().expect("should be object"); @@ -7532,7 +7494,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map["atf_sidebar_ad"] .as_object() @@ -7575,7 +7536,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -7609,7 +7569,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -7651,7 +7610,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -7698,7 +7656,6 @@ mod tests { &test_settings(), "", false, - None, ); let adm = map .get("atf_sidebar_ad") @@ -7749,7 +7706,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -7786,14 +7742,7 @@ mod tests { ); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &settings, - "", - false, - None, - ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); let adm = map .get("atf_sidebar_ad") .and_then(|v| v.as_object()) @@ -7848,7 +7797,6 @@ mod tests { &settings, "http://localhost:7676", false, - None, ); let adm = map .get("atf_sidebar_ad") @@ -7894,14 +7842,7 @@ mod tests { ); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &settings, - "", - false, - None, - ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); let adm = map .get("atf_sidebar_ad") .and_then(|v| v.as_object()) @@ -7942,7 +7883,6 @@ mod tests { &test_settings(), "", false, - None, ); let script = build_bids_script(&map); assert!( @@ -7983,7 +7923,6 @@ mod tests { &test_settings(), "", true, - None, ); let obj = map .get("atf_sidebar_ad") @@ -8054,7 +7993,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -8110,7 +8048,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -8164,7 +8101,6 @@ mod tests { &test_settings(), "", false, - None, ); let obj = map .get("atf_sidebar_ad") @@ -8209,7 +8145,6 @@ mod tests { &test_settings(), "", false, - None, ); assert!( map.is_empty(), diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index aeea99992..a02684362 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -60,10 +60,6 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; - /** Server-side auction ID (response top-level `id` / `ext.ts.auction_id`). */ - auctionId?: string; - /** Trace hash of the delivered adm (`ext.ts.adm_hash`, 16 hex chars of SHA-256). */ - admHash?: string; } // --------------------------------------------------------------------------- @@ -132,7 +128,6 @@ export function parseAuctionResponse(body: any): AuctionBid[] { const bids: AuctionBid[] = []; const seatbids = body?.seatbid; if (!Array.isArray(seatbids)) return bids; - const responseAuctionId = typeof body?.id === 'string' && body.id !== '' ? body.id : undefined; for (const seatbid of seatbids) { const seat: string = typeof seatbid?.seat === 'string' ? seatbid.seat : 'unknown'; @@ -140,7 +135,6 @@ export function parseAuctionResponse(body: any): AuctionBid[] { if (!Array.isArray(seatBids)) continue; for (const bid of seatBids) { - const trace = bid?.ext?.ts; const impid = typeof bid?.impid === 'string' ? bid.impid : ''; const renderer = parseApsRendererDescriptor(bid?.ext?.trusted_server?.renderer); const width = typeof bid?.w === 'number' ? bid.w : (renderer?.width ?? 300); @@ -160,12 +154,6 @@ export function parseAuctionResponse(body: any): AuctionBid[] { height, seat, creativeId, - auctionId: - typeof trace?.auction_id === 'string' && trace.auction_id !== '' - ? trace.auction_id - : responseAuctionId, - admHash: - typeof trace?.adm_hash === 'string' && trace.adm_hash !== '' ? trace.adm_hash : undefined, adomain: Array.isArray(bid?.adomain) ? bid.adomain.filter((domain: unknown): domain is string => typeof domain === 'string') : [], diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index 4b7880e73..ae352c58a 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -2,7 +2,6 @@ import { renderApsCreative } from '../integrations/aps/render'; import { buildAdRequest, sendAuction } from './auction'; -import { recordRender, stampCreativeTrace } from './trace'; import { collectContext } from './context'; import { log } from './log'; import { getAllUnits, firstSize } from './registry'; @@ -22,8 +21,6 @@ type RenderCreativeInlineOptions = { creativeHeight?: number; seat: string; creativeId: string; - auctionId?: string; - admHash?: string; }; // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. @@ -69,8 +66,6 @@ export function requestAds( creativeHeight: bid.height, seat: bid.seat, creativeId: bid.creativeId, - auctionId: bid.auctionId, - admHash: bid.admHash, }); } log.info('requestAds: rendered creatives from response'); @@ -98,22 +93,10 @@ function renderCreativeInline({ creativeHeight, seat, creativeId, - auctionId, - admHash, }: RenderCreativeInlineOptions): void { - const trace = { - slotId, - path: 'auction' as const, - auctionId, - bidder: seat, - creativeId, - admHash, - servedFrom: 'inline' as const, - }; const container = findSlot(slotId) as HTMLElement | null; if (!container) { log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); - recordRender({ ...trace, rendered: false }); return; } @@ -127,14 +110,6 @@ function renderCreativeInline({ originalLength: sanitization.originalLength, rejectionReason: sanitization.rejectionReason, }); - // Stamp rendered:false so the DOM marker semantics match the SSAT path - // (explicit false on a failed render, not just an absent attribute). - const rejectedRecord = recordRender({ - ...trace, - rendered: false, - elementId: container.id || undefined, - }); - stampCreativeTrace(container, rejectedRecord); return; } @@ -166,22 +141,10 @@ function renderCreativeInline({ iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); - // Trace: registry entry + DOM markers joining this creative back to the - // server-side auction (matches the `auction delivered creative:` log line). - const record = recordRender({ - ...trace, - rendered: true, - elementId: container.id || undefined, - }); - stampCreativeTrace(container, record); - stampCreativeTrace(iframe, record); - log.info('renderCreativeInline: rendered', { slotId, seat, creativeId, - auctionId, - admHash, width, height, originalLength: sanitization.originalLength, diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts deleted file mode 100644 index 94edd9fc2..000000000 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Render-trace registry and DOM markers: joins a creative rendered on the -// page back to the winning server-side auction bid. Every render writes a -// RenderRecord to window.tsjs.renders (keyed by slot ID), stamps the slot -// element with data-ts-* attributes carrying the same trace tuple, and fires -// a 'tsjs:adRendered' CustomEvent so tests and tooling can await renders. -import { log } from './log'; -import type { RenderRecord, TsjsApi } from './types'; - -/** CustomEvent fired on window after each render-trace record is written. */ -export const RENDER_EVENT_NAME = 'tsjs:adRendered'; - -/** - * Write a render record into `window.tsjs.renders` and fire the render event. - * - * Repeated records for the same slot (SPA navigation, GPT refresh) overwrite - * the previous entry and increment `count`, so the registry always reflects - * the latest render while preserving how many renders the slot has seen. - */ -export function recordRender(record: Omit): RenderRecord { - const full: RenderRecord = { ...record, count: 1, at: Date.now() }; - try { - const ts = (window.tsjs ??= {} as TsjsApi); - const renders = (ts.renders ??= {}); - const prev = renders[record.slotId]; - if (prev) full.count = prev.count + 1; - renders[record.slotId] = full; - } catch (err) { - log.warn('trace: failed to write render record', { slotId: record.slotId, err }); - } - try { - window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: full })); - } catch (err) { - // CustomEvent unavailable — registry entry above is still written. - log.debug('trace: failed to dispatch render event', { slotId: record.slotId, err }); - } - return full; -} - -/** - * Stamp an element with `data-ts-*` attributes carrying the trace tuple, so - * a creative in the DOM can be joined to the server-side `auction winner:` / - * `auction delivered creative:` log lines by inspection alone. - * - * Attributes whose record field is absent are removed, so a re-render of the - * same element (SPA navigation, GPT refresh) never leaves stale values from a - * previous auction next to the new ones. - */ -export function stampCreativeTrace(el: Element, record: RenderRecord): void { - const attrs: Array<[string, string | undefined]> = [ - ['data-ts-slot-id', record.slotId], - ['data-ts-render-path', record.path], - ['data-ts-rendered', String(record.rendered)], - ['data-ts-auction-id', record.auctionId], - ['data-ts-bidder', record.bidder], - ['data-ts-ad-id', record.adId], - ['data-ts-creative-id', record.creativeId], - ['data-ts-adm-hash', record.admHash], - ['data-ts-served-from', record.servedFrom], - ]; - try { - for (const [name, value] of attrs) { - if (value !== undefined && value !== '') { - el.setAttribute(name, value); - } else { - el.removeAttribute(name); - } - } - } catch (err) { - log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); - } -} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 859095a63..7b81e78a6 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -87,12 +87,6 @@ export interface AuctionBidData { hb_adid?: string; hb_cache_host?: string; hb_cache_path?: string; - /** Server-side auction ID — trace key joining this bid to server logs. */ - hb_auction_id?: string; - /** Upstream creative ID (OpenRTB `crid`), when the bidder returned one. */ - hb_crid?: string; - /** Trace hash of the bid's raw creative markup (16 hex chars of SHA-256). */ - hb_adm_hash?: string; /** Winning creative width; the bridge sizes the inline render from this. */ w?: number; /** Winning creative height; the bridge sizes the inline render from this. */ @@ -119,41 +113,6 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } -/** How a creative reached the page for a [`RenderRecord`]. */ -export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache'; - -/** - * One entry in `window.tsjs.renders` — the client-side half of the render - * trace. Field values mirror the server-side `auction winner:` log line so - * the two can be joined on (auctionId, slotId). - */ -export interface RenderRecord { - /** Slot the creative was rendered for. */ - slotId: string; - /** Which render path produced this record. */ - path: 'auction' | 'ssat'; - /** Whether a creative actually rendered (false for empty/rejected). */ - rendered: boolean; - /** Actual DOM element ID the slot resolved to (div_id may be a prefix). */ - elementId?: string; - /** Server-side auction ID. */ - auctionId?: string; - /** Winning bidder / seat. */ - bidder?: string; - /** hb_adid (PBS cache UUID or OpenRTB adid). */ - adId?: string; - /** Upstream creative ID (OpenRTB crid). */ - creativeId?: string; - /** Trace hash of the creative markup (16 hex chars of SHA-256). */ - admHash?: string; - /** Mechanism that delivered the creative. */ - servedFrom?: RenderServedFrom; - /** How many renders this slot has seen (SPA navigations, refreshes). */ - count: number; - /** Epoch ms when the record was written. */ - at: number; -} - export interface TsjsApi { version: string; que: Array<() => void>; @@ -188,8 +147,6 @@ export interface TsjsApi { apsPrebidRenderers?: Record; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ adInit?: () => void; - /** Render-trace registry: latest render per slot (see [`RenderRecord`]). */ - renders?: Record; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ prevGptSlots?: unknown[]; /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index b7aa9b8ac..8c0dcacba 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,6 +1,5 @@ import { log } from '../../core/log'; -import { recordRender, stampCreativeTrace } from '../../core/trace'; -import type { AuctionSlot, AuctionBidData, RenderServedFrom, TsjsApi } from '../../core/types'; +import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, @@ -369,7 +368,6 @@ function injectAdmIntoSlot(divId: string, adm: string): void { f.width = String(slotEl.offsetWidth || 728); f.height = String(slotEl.offsetHeight || 90); f.setAttribute('sandbox', ADM_IFRAME_SANDBOX); - f.setAttribute('data-ts-injected-adm', 'true'); f.srcdoc = adm; slotEl.appendChild(f); log.debug(`[tsjs-gpt] gam-intercept: replaced slot content for '${divId}'`); @@ -638,20 +636,6 @@ export function installTsAdInit(): void { if (!slotId) return; // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. const bid = (ts.bids ?? {})[slotId] ?? {}; - const record = recordRender({ - slotId, - path: 'ssat', - rendered: !event.isEmpty, - elementId: divId, - auctionId: bid.hb_auction_id, - bidder: bid.hb_bidder, - adId: bid.hb_adid, - creativeId: bid.hb_crid, - admHash: bid.hb_adm_hash, - servedFrom: 'gam', - }); - const slotElement = document.getElementById(divId); - if (slotElement) stampCreativeTrace(slotElement, record); // GAM interceptor (testing bypass): directly replace the GAM creative. // `adm` is now always injected in production, so it can no longer gate @@ -974,39 +958,6 @@ export function parseCachedBid(body: string): CachedBid | undefined { * Lives in gpt/index.ts (not prebid/index.ts) to avoid pulling the full * Prebid bundle into tsjs-gpt.js via inlineDynamicImports. */ -/** - * Trace a creative served by the pbRender bridge: registry entry + DOM markers - * on the slot element. `servedFrom` distinguishes debug adm injection from a - * PBS Cache fetch so verification tooling knows which mechanism delivered the - * markup into the GAM iframe. - * - * `el` must be resolved by the caller at message-receipt time: the PBS Cache - * path stamps only after an async fetch, and re-resolving from live - * `tsjs.adSlots`/DOM at that point could stamp a *new* route's slot with the - * previous page's trace data after an SPA navigation. The connectivity check - * below drops the stamp when the captured element has left the document. - */ -function recordBridgeRender( - slotId: string, - bid: AuctionBidData, - servedFrom: RenderServedFrom, - el: HTMLElement | null -): void { - const record = recordRender({ - slotId, - path: 'ssat', - rendered: true, - elementId: el?.id, - auctionId: bid.hb_auction_id, - bidder: bid.hb_bidder, - adId: bid.hb_adid, - creativeId: bid.hb_crid, - admHash: bid.hb_adm_hash, - servedFrom, - }); - if (el && el.isConnected) stampCreativeTrace(el, record); -} - export function installTsRenderBridge(): void { if (typeof window === 'undefined') return; @@ -1139,10 +1090,6 @@ export function installTsRenderBridge(): void { const [fallbackWidth, fallbackHeight] = slot?.formats?.[0] ?? [728, 90]; const width = matchedBid.w ?? fallbackWidth; const height = matchedBid.h ?? fallbackHeight; - // Resolve the slot element now, at message-receipt time: the PBS Cache - // branch stamps after an async fetch, and by then an SPA navigation may - // have swapped tsjs.adSlots/DOM for a new route with the same slot IDs. - const slotEl = slot ? findSlotElementByDivId(slot.div_id) : null; if (matchedBid.renderer !== undefined) { const renderer = validateApsRenderer(matchedBid.renderer); @@ -1240,7 +1187,6 @@ export function installTsRenderBridge(): void { }) ); fireWinBillingBeacons(slotId, matchedBid); - recordBridgeRender(slotId, matchedBid, 'pbs-cache', slotEl); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) .catch((err) => { diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 276f819a5..7e9bb2947 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -312,40 +312,6 @@ describe('auction/parseAuctionResponse', () => { expect(bids[0].height).toBe(250); expect(bids[0].adomain).toEqual([]); }); - - it('retains the auction id from the response top-level id', () => { - const body = { - id: 'auction-uuid-1', - seatbid: [{ seat: 'kargo', bid: [{ impid: 'slot-1', price: 1.0, adm: '
A
' }] }], - }; - - const bids = parseAuctionResponse(body); - expect(bids[0].auctionId).toBe('auction-uuid-1'); - expect(bids[0].admHash).toBeUndefined(); - }); - - it('prefers bid-level ext.ts trace fields over the top-level id', () => { - const body = { - id: 'auction-uuid-1', - seatbid: [ - { - seat: 'kargo', - bid: [ - { - impid: 'slot-1', - price: 1.0, - adm: '
A
', - ext: { ts: { auction_id: 'auction-uuid-2', adm_hash: 'a1b2c3d4e5f60718' } }, - }, - ], - }, - ], - }; - - const bids = parseAuctionResponse(body); - expect(bids[0].auctionId).toBe('auction-uuid-2'); - expect(bids[0].admHash).toBe('a1b2c3d4e5f60718'); - }); }); describe('auction/sendAuction', () => { diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 62db24003..8dffd825b 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -398,113 +398,6 @@ describe('request.requestAds', () => { ); }); - it('stamps trace markers and records the render in window.tsjs.renders', async () => { - const creativeHtml = '
Traced Creative
'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - id: 'auction-trace-1', - seatbid: [ - { - seat: 'kargo', - bid: [ - { - impid: 'slot1', - adm: creativeHtml, - crid: 'cr-777', - ext: { ts: { auction_id: 'auction-trace-1', adm_hash: 'a1b2c3d4e5f60718' } }, - }, - ], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - const { RENDER_EVENT_NAME } = await import('../../src/core/trace'); - const eventListener = vi.fn(); - window.addEventListener(RENDER_EVENT_NAME, eventListener); - - document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - - requestAds(); - await flushRequestAds(); - - const container = document.querySelector('#slot1') as HTMLElement; - expect(container.getAttribute('data-ts-slot-id')).toBe('slot1'); - expect(container.getAttribute('data-ts-render-path')).toBe('auction'); - expect(container.getAttribute('data-ts-rendered')).toBe('true'); - expect(container.getAttribute('data-ts-auction-id')).toBe('auction-trace-1'); - expect(container.getAttribute('data-ts-bidder')).toBe('kargo'); - expect(container.getAttribute('data-ts-creative-id')).toBe('cr-777'); - expect(container.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); - - const iframe = container.querySelector('iframe') as HTMLIFrameElement; - expect(iframe.getAttribute('data-ts-slot-id')).toBe('slot1'); - expect(iframe.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); - - const record = (window as any).tsjs?.renders?.['slot1']; - expect(record).toEqual( - expect.objectContaining({ - slotId: 'slot1', - path: 'auction', - rendered: true, - elementId: 'slot1', - auctionId: 'auction-trace-1', - bidder: 'kargo', - creativeId: 'cr-777', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'inline', - }) - ); - expect(eventListener).toHaveBeenCalledTimes(1); - - window.removeEventListener(RENDER_EVENT_NAME, eventListener); - }); - - it('records a rendered:false trace entry when the creative is rejected', async () => { - (globalThis as any).fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: { get: () => 'application/json' }, - json: async () => ({ - id: 'auction-trace-2', - seatbid: [ - { - seat: 'appnexus', - bid: [{ impid: 'slot1', adm: ' ', crid: 'creative-empty' }], - }, - ], - }), - }); - - const { addAdUnits } = await import('../../src/core/registry'); - const { requestAds } = await import('../../src/core/request'); - - document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - - requestAds(); - await flushRequestAds(); - - const record = (window as any).tsjs?.renders?.['slot1']; - expect(record).toEqual( - expect.objectContaining({ - slotId: 'slot1', - path: 'auction', - rendered: false, - auctionId: 'auction-trace-2', - }) - ); - // Rejected creative must stamp an explicit rendered:false marker, - // matching the SSAT path's empty-render semantics. - expect(document.querySelector('#slot1')?.getAttribute('data-ts-rendered')).toBe('false'); - }); - it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML (globalThis as any).fetch = vi.fn().mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts deleted file mode 100644 index ba7a640e2..000000000 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { recordRender, stampCreativeTrace, RENDER_EVENT_NAME } from '../../src/core/trace'; -import type { RenderRecord, TsjsApi } from '../../src/core/types'; - -describe('trace/recordRender', () => { - beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; - }); - - it('writes a render record into window.tsjs.renders', () => { - const record = recordRender({ - slotId: 'slot-1', - path: 'auction', - rendered: true, - elementId: 'slot-1', - auctionId: 'auction-abc', - bidder: 'kargo', - creativeId: 'cr-1', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'inline', - }); - - expect(window.tsjs?.renders?.['slot-1']).toEqual(record); - expect(record.count).toBe(1); - expect(record.at).toBeGreaterThan(0); - }); - - it('overwrites the previous record and increments count on re-render', () => { - recordRender({ slotId: 'slot-1', path: 'ssat', rendered: true, auctionId: 'a-1' }); - const second = recordRender({ - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'a-2', - }); - - const entry = window.tsjs?.renders?.['slot-1']; - expect(entry?.auctionId).toBe('a-2'); - expect(entry?.count).toBe(2); - expect(second.count).toBe(2); - }); - - it('fires a tsjs:adRendered CustomEvent with the record as detail', () => { - const listener = vi.fn(); - window.addEventListener(RENDER_EVENT_NAME, listener); - - const record = recordRender({ slotId: 'slot-ev', path: 'auction', rendered: true }); - - expect(listener).toHaveBeenCalledTimes(1); - const event = listener.mock.calls[0][0] as CustomEvent; - expect(event.detail).toEqual(record); - - window.removeEventListener(RENDER_EVENT_NAME, listener); - }); -}); - -describe('trace/stampCreativeTrace', () => { - it('stamps data-ts-* attributes for present fields only', () => { - const el = document.createElement('div'); - const record: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'ts-req-abc', - bidder: 'kargo', - adId: 'cache-uuid-1', - admHash: 'a1b2c3d4e5f60718', - count: 1, - at: 1, - }; - - stampCreativeTrace(el, record); - - expect(el.getAttribute('data-ts-slot-id')).toBe('slot-1'); - expect(el.getAttribute('data-ts-render-path')).toBe('ssat'); - expect(el.getAttribute('data-ts-rendered')).toBe('true'); - expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-abc'); - expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); - expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-1'); - expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); - // creativeId absent — attribute must not exist. - expect(el.hasAttribute('data-ts-creative-id')).toBe(false); - }); - - it('removes stale attributes when a re-render lacks a field', () => { - const el = document.createElement('div'); - const first: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'auction-old', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'gam', - count: 1, - at: 1, - }; - stampCreativeTrace(el, first); - - const second: RenderRecord = { - slotId: 'slot-1', - path: 'ssat', - rendered: true, - auctionId: 'auction-new', - count: 2, - at: 2, - }; - stampCreativeTrace(el, second); - - expect(el.getAttribute('data-ts-auction-id')).toBe('auction-new'); - // The previous auction's hash and mechanism must not survive the re-stamp. - expect(el.hasAttribute('data-ts-adm-hash')).toBe(false); - expect(el.hasAttribute('data-ts-served-from')).toBe(false); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 3d7dfa978..c790d37a0 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -635,92 +635,6 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); - it('stamps trace markers and records the render on slotRenderEnded', async () => { - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'cache-uuid-9', - hb_auction_id: 'ts-req-trace9', - hb_crid: 'cr-98765', - hb_adm_hash: 'a1b2c3d4e5f60718', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - - const el = document.getElementById('div-atf-sidebar')!; - expect(el.getAttribute('data-ts-slot-id')).toBe('atf_sidebar_ad'); - expect(el.getAttribute('data-ts-render-path')).toBe('ssat'); - expect(el.getAttribute('data-ts-rendered')).toBe('true'); - expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-trace9'); - expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); - expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-9'); - expect(el.getAttribute('data-ts-creative-id')).toBe('cr-98765'); - expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); - - const record = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; - expect(record).toEqual( - expect.objectContaining({ - slotId: 'atf_sidebar_ad', - path: 'ssat', - rendered: true, - elementId: 'div-atf-sidebar', - auctionId: 'ts-req-trace9', - bidder: 'kargo', - adId: 'cache-uuid-9', - creativeId: 'cr-98765', - admHash: 'a1b2c3d4e5f60718', - servedFrom: 'gam', - }) - ); - - // An empty render must record rendered:false and bump the count. - capturedListener!({ isEmpty: true, slot: mockSlot }); - const second = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; - expect(second?.rendered).toBe(false); - expect(second?.count).toBe(2); - expect(el.getAttribute('data-ts-rendered')).toBe('false'); - }); - it('does not fire beacons for an APS-style bid that carries no hb_adid', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; From 3b02916a04cb67d24a976223aca4956544c36e2c Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 12:33:47 -0500 Subject: [PATCH 104/494] Track effective GPT initial-load configuration --- .../src/integrations/gpt.rs | 4 + .../src/integrations/gpt_bootstrap.js | 37 +++- .../lib/src/integrations/gpt/index.ts | 41 +++-- .../lib/test/integrations/gpt/ad_init.test.ts | 158 +++++++++++++++++- 4 files changed, 215 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 539d01610..0bd72bb6b 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1265,6 +1265,10 @@ mod tests { combined.contains("gpt.setConfig"), "bootstrap should wrap googletag.setConfig() to detect the disabled state" ); + assert!( + combined.contains("gpt.getConfig"), + "bootstrap should read GPT's modern initial-load configuration" + ); assert!( combined.contains("pubads.disableInitialLoad"), "bootstrap should wrap legacy disableInitialLoad() calls" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 9abf556c8..1ea331793 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -19,25 +19,42 @@ var ts = (window.tsjs = window.tsjs || {}); if (ts.adInit) return; - // Track whether the publisher disabled GPT initial load. GPT exposes no - // getter for this, so wrap both googletag.setConfig() and the legacy - // pubads().disableInitialLoad() method to record it. With initial load + // Track whether the publisher disabled GPT initial load. Read the modern + // googletag.getConfig() value when available, and wrap googletag.setConfig() + // and the legacy pubads().disableInitialLoad() method as fallbacks because + // getConfig() may not report the legacy API's state. With initial load // disabled, display() only registers a slot and the ad request must come from // a later refresh(); adInit() reads this to refresh its own freshly defined // slots so they are not left blank. Pushed onto the command queue so it runs // before the publisher's own GPT configuration. + function syncInitialLoadDisabled(gpt) { + if (typeof gpt.getConfig !== "function") return false; + var config = gpt.getConfig("disableInitialLoad"); + if (!config || typeof config.disableInitialLoad === "undefined") { + return false; + } + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + return true; + } + (window.googletag = window.googletag || { cmd: [] }).cmd.push(function () { var gpt = window.googletag; + syncInitialLoadDisabled(gpt); if ( typeof gpt.setConfig === "function" && !gpt.__tsInitialLoadConfigHooked ) { var originalSetConfig = gpt.setConfig.bind(gpt); gpt.setConfig = function (config) { - if (config && config.disableInitialLoad === true) { - ts.gptInitialLoadDisabled = true; + var result = originalSetConfig.apply(gpt, arguments); + if ( + !syncInitialLoadDisabled(gpt) && + config && + "disableInitialLoad" in config + ) { + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; } - return originalSetConfig(config); + return result; }; gpt.__tsInitialLoadConfigHooked = true; } @@ -52,8 +69,11 @@ } var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads); pubads.disableInitialLoad = function () { - ts.gptInitialLoadDisabled = true; - return originalDisableInitialLoad(); + var result = originalDisableInitialLoad.apply(pubads, arguments); + if (!syncInitialLoadDisabled(gpt)) { + ts.gptInitialLoadDisabled = true; + } + return result; }; pubads.__tsInitialLoadHooked = true; }); @@ -166,6 +186,7 @@ // unless the publisher disabled initial load, in which case display() only // registers them and refresh() must request the ad — otherwise they render // blank. Only add them in that case to avoid double-requesting. + syncInitialLoadDisabled(window.googletag); var slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 8d5263b9b..c82e16f68 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -110,7 +110,7 @@ interface GoogleTagPubAdsService { } interface GoogleTagConfig extends Record { - disableInitialLoad?: boolean; + disableInitialLoad?: boolean | null; } interface GoogleTag { @@ -124,7 +124,8 @@ interface GoogleTag { destroySlots(slots?: GoogleTagSlot[]): boolean; enableServices(): void; display(elementId: string): void; - setConfig(config: GoogleTagConfig): void; + setConfig?(config: GoogleTagConfig): void; + getConfig?(key: 'disableInitialLoad'): GoogleTagConfig | undefined; _loaded_?: boolean; } @@ -414,9 +415,9 @@ function queueWinBillingBeacon(url: string): boolean { /** * Track whether the publisher disabled GPT initial load. * - * GPT exposes no getter for the initial-load-disabled flag, so wrap both the - * modern `googletag.setConfig({ disableInitialLoad: true })` API and the legacy - * `pubads().disableInitialLoad()` method to record it on `window.tsjs`. With + * GPT's modern `getConfig()` getter may not report state set through the legacy + * `pubads().disableInitialLoad()` API, so read it when available and wrap both + * configuration APIs to record the state on `window.tsjs`. With * initial load disabled, `display()` only registers a slot — the ad request * must come from a later `refresh()`. adInit() reads this to refresh its own * freshly defined slots so they are not left blank. @@ -431,6 +432,16 @@ function queueWinBillingBeacon(url: string): boolean { * `installTsAdInit` runs, so the detector is still queued ahead of the * publisher's GPT setup. */ +function syncInitialLoadDisabled(gpt: Partial, ts: TsjsApi): boolean { + if (typeof gpt.getConfig !== 'function') return false; + + const config = gpt.getConfig('disableInitialLoad'); + if (!config || config.disableInitialLoad === undefined) return false; + + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; + return true; +} + function installInitialLoadDetector(ts: TsjsApi): void { const win = window as GptWindow; const cmd = win.googletag?.cmd; @@ -441,13 +452,17 @@ function installInitialLoadDetector(ts: TsjsApi): void { | undefined; if (!gpt) return; + syncInitialLoadDisabled(gpt, ts); + if (typeof gpt.setConfig === 'function' && !gpt.__tsInitialLoadConfigHooked) { const originalSetConfig = gpt.setConfig.bind(gpt); - gpt.setConfig = function (config: GoogleTagConfig) { - if (config?.disableInitialLoad === true) { - ts.gptInitialLoadDisabled = true; + gpt.setConfig = function (...args: Parameters) { + const config = args[0]; + const result = originalSetConfig(...args); + if (!syncInitialLoadDisabled(gpt, ts) && config && 'disableInitialLoad' in config) { + ts.gptInitialLoadDisabled = config.disableInitialLoad === true; } - return originalSetConfig(config); + return result; }; gpt.__tsInitialLoadConfigHooked = true; } @@ -460,8 +475,11 @@ function installInitialLoadDetector(ts: TsjsApi): void { } const originalDisableInitialLoad = service.disableInitialLoad.bind(service); service.disableInitialLoad = function () { - ts.gptInitialLoadDisabled = true; - return originalDisableInitialLoad(); + const result = originalDisableInitialLoad(); + if (!syncInitialLoadDisabled(gpt, ts)) { + ts.gptInitialLoadDisabled = true; + } + return result; }; service.__tsInitialLoadHooked = true; }); @@ -640,6 +658,7 @@ export function installTsAdInit(): void { // first-impression slot renders blank on initial-load-disabled pages. Only // add them in that case; otherwise display() + refresh() would // double-request the impression. + syncInitialLoadDisabled(g, ts); const slotsNeedingRefresh = ts.gptInitialLoadDisabled ? slotsToRefresh.concat(newSlots) : slotsToRefresh; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 8f943e08c..a21d82297 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; // Track every 'message' EventListener added to window across the entire test @@ -205,6 +208,7 @@ describe('installTsAdInit', () => { refresh: vi.fn(), disableInitialLoad: vi.fn(), }; + const getConfigMock = vi.fn().mockReturnValue(undefined); const displayMock = vi.fn(); (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, @@ -212,6 +216,8 @@ describe('installTsAdInit', () => { display: displayMock, pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), + // GPT's modern getter does not report legacy disableInitialLoad() state. + getConfig: getConfigMock, }; (window as TestWindow).tsjs = { adSlots: [ @@ -243,7 +249,65 @@ describe('installTsAdInit', () => { expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); }); - it('refreshes TS-defined slots when setConfig disables GPT initial load', async () => { + it('keeps the legacy disabled state in the edge bootstrap when getConfig is unavailable', async () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + }; + const disableInitialLoadMock = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + refresh: vi.fn(), + disableInitialLoad: disableInitialLoadMock, + }; + const displayMock = vi.fn(); + const getConfigMock = vi.fn().mockReturnValue(undefined); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: displayMock, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + getConfig: getConfigMock, + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const bootstrap = await readFile( + path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), + 'utf8' + ); + const runBootstrap = new Function('window', 'googletag', bootstrap) as ( + window: Window, + googletag: object + ) => void; + runBootstrap(window, googletag); + + mockPubads.disableInitialLoad(); + expect(disableInitialLoadMock).toHaveBeenCalledOnce(); + expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); + + (window as TestWindow).tsjs!.adInit!(); + + expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + }); + + it('tracks the effective initial-load state from setConfig', async () => { // Modern GPT configuration uses googletag.setConfig() rather than the // legacy pubads().disableInitialLoad() method. TS must detect both forms. const mockSlot = { @@ -260,13 +324,24 @@ describe('installTsAdInit', () => { refresh: vi.fn(), }; const displayMock = vi.fn(); - const setConfigMock = vi.fn(); + type InitialLoadConfig = { + disableInitialLoad?: boolean | null; + singleRequest?: boolean; + }; + let effectiveConfig: InitialLoadConfig = {}; + const setConfigMock = vi.fn((config: InitialLoadConfig) => { + if ('disableInitialLoad' in config) { + effectiveConfig = { disableInitialLoad: config.disableInitialLoad }; + } + }); + const getConfigMock = vi.fn(() => effectiveConfig); (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue(mockSlot), display: displayMock, pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), + getConfig: getConfigMock, setConfig: setConfigMock, }; (window as TestWindow).tsjs = { @@ -285,12 +360,83 @@ describe('installTsAdInit', () => { const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); installTsAdInit(); + installTsAdInit(); + + const gpt = (window as TestWindow).googletag as { + setConfig(config: InitialLoadConfig): void; + }; + gpt.setConfig({ singleRequest: true }); + expect(setConfigMock).toHaveBeenCalledOnce(); + expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).not.toBe(true); + + (window as TestWindow).tsjs!.adInit!(); + + expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); + expect(mockPubads.refresh).not.toHaveBeenCalled(); const config = { disableInitialLoad: true, singleRequest: true }; - ((window as TestWindow).googletag as { setConfig(value: typeof config): void }).setConfig( - config - ); - expect(setConfigMock).toHaveBeenCalledWith(config); + gpt.setConfig(config); + expect(setConfigMock).toHaveBeenCalledTimes(2); + expect(setConfigMock).toHaveBeenLastCalledWith(config); + expect(getConfigMock).toHaveBeenCalledWith('disableInitialLoad'); + expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + + mockPubads.refresh.mockClear(); + gpt.setConfig({ disableInitialLoad: false }); + expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); + gpt.setConfig({ disableInitialLoad: null }); + expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockPubads.refresh).not.toHaveBeenCalled(); + }); + + it('reads initial-load configuration effective before detector installation', async () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const displayMock = vi.fn(); + const getConfigMock = vi.fn().mockReturnValue({ disableInitialLoad: true }); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: displayMock, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + getConfig: getConfigMock, + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + expect(getConfigMock).toHaveBeenCalledWith('disableInitialLoad'); expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); (window as TestWindow).tsjs!.adInit!(); From b9ce68b345e36abaa6be50f021c369637edb1ff5 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 12:35:26 -0500 Subject: [PATCH 105/494] Document GPT initial-load getter fallback --- crates/trusted-server-js/lib/src/core/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index e791355e6..193f6912f 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -116,7 +116,8 @@ export interface TsjsApi { /** * True once the publisher has disabled GPT initial load through * `googletag.setConfig()` or `googletag.pubads().disableInitialLoad()`. - * GPT exposes no getter for this state, so TS tracks both configuration APIs. + * GPT's getter may not report state set through the legacy API, so TS tracks + * both configuration APIs. * When set, `display()` only registers a slot and the ad request must come * from a `refresh()`; adInit() uses this to refresh its own freshly defined * slots so they are not left blank. From 050ad9caa3b265ae279aa2e4d702e2dcba133113 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 11:56:34 -0500 Subject: [PATCH 106/494] Add privacy-safe auction-to-creative tracing Introduce query-activated diagnostic sessions, trace identities and telemetry, bounded GPT and Prebid render ownership, creative acknowledgements, and the browser timeline overlay. Add deterministic integration fixtures and coverage across edge adapters and browser render paths. --- .../setup-integration-test-env/action.yml | 29 + .github/workflows/integration-tests.yml | 23 +- crates/trusted-server-adapter-axum/src/app.rs | 3 +- .../src/middleware.rs | 46 + .../src/app.rs | 3 +- .../src/middleware.rs | 45 + .../trusted-server-adapter-fastly/src/app.rs | 3 +- .../trusted-server-adapter-fastly/src/main.rs | 2 + .../src/middleware.rs | 44 + .../src/tinybird.rs | 1 + crates/trusted-server-adapter-spin/src/app.rs | 9 +- .../src/middleware.rs | 38 +- .../benches/html_processor_bench.rs | 1 + .../src/auction/endpoints.rs | 42 +- .../src/auction/formats.rs | 244 +++-- .../src/auction/orchestrator.rs | 519 ++++++--- .../src/auction/telemetry.rs | 222 ++-- .../src/auction/test_support.rs | 10 +- .../trusted-server-core/src/auction/types.rs | 147 +++ crates/trusted-server-core/src/config.rs | 11 +- crates/trusted-server-core/src/constants.rs | 4 + .../trusted-server-core/src/html_processor.rs | 28 +- .../src/integrations/ad_trace.rs | 620 +++++++++++ .../src/integrations/aps.rs | 3 + .../src/integrations/gpt_bootstrap.js | 54 + .../src/integrations/mod.rs | 5 + .../src/integrations/prebid.rs | 23 +- crates/trusted-server-core/src/openrtb.rs | 33 +- crates/trusted-server-core/src/publisher.rs | 476 +++++++-- .../src/response_privacy.rs | 12 +- .../browser/global-setup.ts | 13 +- .../browser/helpers/infra.ts | 1 + .../browser/helpers/state.ts | 2 +- .../browser/package.json | 1 + .../browser/playwright.config.ts | 8 +- .../tests/ad-trace/auction-trace.spec.ts | 439 ++++++++ .../tests/shared/ad-trace-gate.spec.ts | 20 + .../trusted-server.ad-trace.integration.toml | 67 ++ .../fixtures/frameworks/ad-trace/Dockerfile | 15 + .../frameworks/ad-trace/public/index.php | 201 ++++ .../frameworks/ad-trace/public/router.php | 50 + .../tests/parity.rs | 71 ++ .../lib/src/core/ad_trace.ts | 505 +++++++++ .../trusted-server-js/lib/src/core/auction.ts | 154 ++- .../lib/src/core/global.d.ts | 2 + .../trusted-server-js/lib/src/core/request.ts | 216 +++- .../trusted-server-js/lib/src/core/types.ts | 202 +++- .../lib/src/integrations/ad_trace/index.ts | 98 ++ .../lib/src/integrations/ad_trace/overlay.ts | 231 ++++ .../lib/src/integrations/gpt/index.ts | 985 +++++++++++++++++- .../lib/src/integrations/prebid/index.ts | 175 +++- .../lib/test/core/ad_trace.test.ts | 332 ++++++ .../lib/test/core/auction.test.ts | 153 ++- .../lib/test/core/request.test.ts | 142 ++- .../test/integrations/ad_trace/index.test.ts | 41 + .../integrations/ad_trace/overlay.test.ts | 110 ++ .../lib/test/integrations/gpt/ad_init.test.ts | 287 ++++- .../test/integrations/gpt/ad_trace.test.ts | 327 ++++++ .../lib/test/integrations/gpt/index.test.ts | 31 +- .../test/integrations/prebid/index.test.ts | 80 +- docs/guide/configuration.md | 17 + .../generate-integration-viceroy-configs.sh | 7 + scripts/integration-tests-browser.sh | 30 +- .../datasources/auction_events_raw.datasource | 1 + tinybird/fixtures/auction_events_raw.ndjson | 2 +- trusted-server.example.toml | 5 + 66 files changed, 7104 insertions(+), 617 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/ad_trace.rs create mode 100644 crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts create mode 100644 crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php create mode 100644 crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php create mode 100644 crates/trusted-server-js/lib/src/core/ad_trace.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts create mode 100644 crates/trusted-server-js/lib/test/core/ad_trace.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 841d8d5bd..12c41502a 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -93,6 +93,26 @@ runs: TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build -p trusted-server-adapter-axum + - name: Set up Node.js for browser fixtures + if: ${{ inputs.build-test-images == 'true' }} + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + + - name: Build external Prebid fixture bundle + if: ${{ inputs.build-test-images == 'true' }} + shell: bash + run: | + rm -rf "$GITHUB_WORKSPACE/target/integration-test-artifacts/prebid" + mkdir -p "$GITHUB_WORKSPACE/target/integration-test-artifacts/prebid" + npm ci --prefix crates/trusted-server-js/lib + npm run --prefix crates/trusted-server-js/lib build:prebid-external -- \ + --adapters=rubicon \ + --user-id-modules=sharedIdSystem \ + --out "$GITHUB_WORKSPACE/target/integration-test-artifacts/prebid" + - name: Build WordPress test container if: ${{ inputs.build-test-images == 'true' }} shell: bash @@ -109,6 +129,15 @@ runs: -t test-nextjs:latest \ crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ + - name: Build ad-trace test container + if: ${{ inputs.build-test-images == 'true' }} + shell: bash + run: | + docker build \ + -f crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile \ + -t test-ad-trace:latest \ + . + - name: Add wasm32-unknown-unknown target for Cloudflare build if: ${{ inputs.build-cloudflare == 'true' }} shell: bash diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4973afe44..36c4a8f6d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -46,7 +46,7 @@ jobs: cp -r crates/trusted-server-adapter-cloudflare/build/. "$CF_BUILD_ARTIFACT_PATH/" docker save \ --output "$DOCKER_ARTIFACT_PATH" \ - test-wordpress:latest test-nextjs:latest + test-ad-trace:latest test-wordpress:latest test-nextjs:latest - name: Upload integration test artifacts uses: actions/upload-artifact@v4 @@ -237,10 +237,29 @@ jobs: path: crates/trusted-server-integration-tests/browser/playwright-report-wordpress/ retention-days: 7 + - name: Run browser tests (ad trace contract) + if: always() + working-directory: crates/trusted-server-integration-tests/browser + env: + WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-ad-trace.toml + TEST_FRAMEWORK: ad-trace + PLAYWRIGHT_HTML_REPORT: playwright-report-ad-trace + run: npx playwright test tests/ad-trace/auction-trace.spec.ts + + - name: Upload Playwright report (ad trace contract) + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-ad-trace + path: crates/trusted-server-integration-tests/browser/playwright-report-ad-trace/ + retention-days: 7 + - name: Upload Playwright traces and screenshots uses: actions/upload-artifact@v4 if: failure() with: name: playwright-traces - path: crates/trusted-server-integration-tests/browser/test-results/ + path: crates/trusted-server-integration-tests/browser/test-results-*/ retention-days: 7 diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..fe18e3604 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -32,7 +32,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AdTracePrepareMiddleware, AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{AxumPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- @@ -540,6 +540,7 @@ fn build_router(state: &Arc) -> RouterService { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AdTracePrepareMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); router = router.route("/health", Method::GET, |_ctx: RequestContext| async { diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 45cbedc2c..f3ea0d198 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -5,6 +5,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::settings::Settings; @@ -38,6 +39,51 @@ impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AdTracePrepareMiddleware +// --------------------------------------------------------------------------- + +/// Prepares and sanitizes the request before auth, routing, or downstream use. +pub struct AdTracePrepareMiddleware { + settings: Arc, +} + +impl AdTracePrepareMiddleware { + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AdTracePrepareMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); Ok(response) } } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f6..798e2a2e0 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -29,7 +29,7 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AdTracePrepareMiddleware, AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -432,6 +432,7 @@ fn build_router(state: &Arc) -> RouterService { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AdTracePrepareMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( "/.well-known/trusted-server.json", diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 5b605bcff..de15ac62b 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -5,6 +5,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::settings::Settings; @@ -46,6 +47,50 @@ impl Middleware for FinalizeResponseMiddleware { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AdTracePrepareMiddleware +// --------------------------------------------------------------------------- + +pub struct AdTracePrepareMiddleware { + settings: Arc, +} + +impl AdTracePrepareMiddleware { + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AdTracePrepareMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); Ok(response) } } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ae4ca421f..1700c3969 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -129,7 +129,7 @@ use trusted_server_core::settings_data::{ }; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AdTracePrepareMiddleware, AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, FastlyPlatformSecretStore, UnavailableKvStore, open_kv_store, @@ -1159,6 +1159,7 @@ impl TrustedServerApp { Arc::clone(&state.settings), Arc::new(FastlyPlatformGeo), )) + .middleware(AdTracePrepareMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); let fallback_handler = fallback_route_handler(Arc::clone(state)); diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 8fec21435..e6cda086f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -336,6 +336,8 @@ fn send_edgezero_response( // added a per-user Set-Cookie after `apply_finalize_headers` ran, so // re-apply the privacy downgrade before send. crate::middleware::enforce_set_cookie_cache_privacy(&mut response); + // Reassert console no-store after asset/EC/filter response mutations. + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); let (parts, body) = response.into_parts(); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 2c00ac2ff..f6a674301 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -85,6 +85,7 @@ impl Middleware for FinalizeResponseMiddleware { }); apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); response .headers_mut() .insert(HEADER_X_TS_FINALIZED, HeaderValue::from_static("1")); @@ -93,6 +94,49 @@ impl Middleware for FinalizeResponseMiddleware { } } +// --------------------------------------------------------------------------- +// AdTracePrepareMiddleware +// --------------------------------------------------------------------------- + +/// Sanitizes and snapshots the console decision before auth and route dispatch. +pub struct AdTracePrepareMiddleware { + settings: Arc, +} + +impl AdTracePrepareMiddleware { + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AdTracePrepareMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); + Ok(response) + } +} + // --------------------------------------------------------------------------- // AuthMiddleware // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index f2df61744..8ca1bdc16 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -419,6 +419,7 @@ mod tests { price_cpm: None, currency: None, is_win: None, + bid_trace_id: None, ad_domain: None, ad_id: None, } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce74..dbfb5c4fd 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -705,13 +705,10 @@ fn build_router(state: &Arc) -> RouterService { let mut builder = RouterService::builder() .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + // Normalize and sanitize outside auth so even auth short-circuits + // cannot forward reserved console inputs or skip response actions. + .middleware(NormalizeMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) - // Innermost middleware: normalize every routed request (strip - // spoofable forwarded headers, derive the trusted Host/scheme/client-IP - // from Spin's synthetic runtime headers) so no handler can opt out of - // the de-spoofing invariant. Runs after auth so the basic-auth gate - // continues to see the original request, matching prior behaviour. - .middleware(NormalizeMiddleware::new()) // Cheap liveness probe, matching the Fastly/Axum adapters. Registered // explicitly so it is not absorbed by the publisher `/{*rest}` fallback. .get("/health", |_ctx: RequestContext| async { diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 1bcede1fc..1f9178057 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -5,6 +5,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::response::IntoResponse; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::settings::Settings; @@ -39,6 +40,7 @@ impl Middleware for FinalizeResponseMiddleware { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); + trusted_server_core::integrations::ad_trace::finalize_response(&mut response); Ok(response) } } @@ -95,16 +97,17 @@ impl Middleware for AuthMiddleware { /// signing handler that begins deriving an issuer/audience from `RequestInfo`, /// cannot silently trust spoofable input by forgetting to opt in. /// -/// Registered after [`AuthMiddleware`] (innermost) so the basic-auth gate still -/// evaluates the original request, preserving prior behaviour. -#[derive(Default)] -pub struct NormalizeMiddleware; +/// Registered outside [`AuthMiddleware`] so de-spoofing and console sanitation +/// also apply when auth short-circuits the request. +pub struct NormalizeMiddleware { + settings: Arc, +} impl NormalizeMiddleware { /// Creates a new [`NormalizeMiddleware`]. #[must_use] - pub fn new() -> Self { - Self + pub fn new(settings: Arc) -> Self { + Self { settings } } } @@ -112,7 +115,28 @@ impl NormalizeMiddleware { impl Middleware for NormalizeMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { crate::app::normalize_spin_request(ctx.request_mut()); - next.run(ctx).await + let decision = match trusted_server_core::integrations::ad_trace::prepare_request( + &self.settings, + ctx.request_mut(), + ) { + Ok(decision) => decision, + Err(report) => { + log::error!("ad trace request preparation failed: {report:?}"); + return Ok(crate::app::http_error(&report)); + } + }; + let mut response = match next.run(ctx).await { + Ok(response) => response, + Err(error) => { + log::error!("request handler failed after ad trace preparation: {error:?}"); + error.into_response()? + } + }; + trusted_server_core::integrations::ad_trace::attach_response_decision( + &decision, + &mut response, + ); + Ok(response) } } diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 6c7a397b0..24b034ec9 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -9,6 +9,7 @@ fn make_config() -> HtmlProcessorConfig { request_host: "proxy.bench.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index e5796323f..74a2b7ab1 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,7 +1,5 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; - use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{Request, Response, StatusCode, header}; @@ -24,12 +22,12 @@ use crate::platform::RuntimeServices; use crate::settings::Settings; use super::AuctionOrchestrator; -use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request}; +use super::formats::{convert_to_openrtb_response_with_trace, convert_tsjs_to_auction_request}; use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::AuctionContext; +use super::types::{AuctionContext, AuctionPublicOutcome, AuctionTraceContext}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -160,6 +158,8 @@ pub async fn handle_auction( ); let http_req = Request::from_parts(parts, EdgeBody::empty()); + let trace_enabled = crate::integrations::ad_trace::browser_trace_enabled(&http_req); + let trace = AuctionTraceContext::new(AuctionSource::AuctionApi); // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. @@ -193,11 +193,8 @@ pub async fn handle_auction( ec_id, None, )?; - let observation = AuctionObservationContext::from_auction_request( - AuctionSource::AuctionApi, - &auction_request, - ec_context, - ); + let observation = + AuctionObservationContext::from_auction_request(&trace, &auction_request, ec_context); emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -209,18 +206,13 @@ pub async fn handle_auction( }) .await; - let empty_result = OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 0, - metadata: HashMap::new(), - }; - return convert_to_openrtb_response( + let empty_result = OrchestrationResult::empty(trace, AuctionPublicOutcome::Skipped); + return convert_to_openrtb_response_with_trace( &empty_result, settings, &auction_request, ec_context.ec_allowed(), + trace_enabled, ); } @@ -282,6 +274,7 @@ pub async fn handle_auction( // Create auction context let context = AuctionContext { + trace: &trace, settings, request: &http_req, timeout_ms: settings.auction.timeout_ms, @@ -289,11 +282,8 @@ pub async fn handle_auction( services, }; - let observation = AuctionObservationContext::from_auction_request( - AuctionSource::AuctionApi, - &auction_request, - ec_context, - ); + let observation = + AuctionObservationContext::from_auction_request(&trace, &auction_request, ec_context); // Run the auction let result = match orchestrator.run_auction(&auction_request, &context).await { @@ -337,7 +327,13 @@ pub async fn handle_auction( ); // Convert to OpenRTB response format with inline creative HTML - convert_to_openrtb_response(&result, settings, &auction_request, ec_context.ec_allowed()) + convert_to_openrtb_response_with_trace( + &result, + settings, + &auction_request, + ec_context.ec_allowed(), + trace_enabled, + ) } /// Resolves partner EIDs from the KV identity graph for bidstream decoration. diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 284db6242..62fbef1f9 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -21,8 +21,8 @@ use crate::ec::eids::encode_eids_header; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::openrtb::{ - BidExt, BidTrustedServerExt, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt, - to_openrtb_i32, + AuctionTraceWire, BidExt, BidTraceWire, BidTrustedServerExt, OpenRtbBid, OpenRtbResponse, + ResponseExt, SeatBid, ToExt, TrustedServerResponseExt, to_openrtb_i32, }; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -263,6 +263,21 @@ pub fn convert_to_openrtb_response( settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, +) -> Result, Report> { + convert_to_openrtb_response_with_trace(result, settings, auction_request, ec_allowed, false) +} + +/// Convert an auction result with optional tester-gated trace extensions. +/// +/// # Errors +/// +/// Returns the same errors as [`convert_to_openrtb_response`]. +pub fn convert_to_openrtb_response_with_trace( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, + trace_enabled: bool, ) -> Result, Report> { // Build OpenRTB-style seatbid array let mut seatbids = Vec::with_capacity(result.winning_bids.len()); @@ -286,7 +301,7 @@ pub fn convert_to_openrtb_response( // Ordinary markup remains on the mandatory sanitize/rewrite path. A // typed renderer is serialized separately and never enters the HTML sanitizer. - let (adm, ext) = if let Some(ref raw_creative) = bid.creative { + let (adm, renderer) = if let Some(ref raw_creative) = bid.creative { let sanitize_creatives = settings.auction.sanitize_creatives; let sanitized = if sanitize_creatives { creative::sanitize_creative_html(raw_creative) @@ -325,13 +340,7 @@ pub fn convert_to_openrtb_response( (Some(processed), None) } else if let Some(renderer) = bid.renderer.as_ref() { - ( - None, - BidExt { - trusted_server: BidTrustedServerExt { renderer }, - } - .to_ext(), - ) + (None, Some(renderer)) } else { return Err(Report::new(TrustedServerError::Auction { message: format!( @@ -341,6 +350,28 @@ pub fn convert_to_openrtb_response( })); }; + let bid_trace = trace_enabled + .then(|| result.trace.winning_bids.get(slot_id)) + .flatten() + .map(|trace| BidTraceWire { + version: 1, + bid_trace_id: trace.bid_trace_id.to_string(), + slot_id: slot_id.clone(), + provider: trace.provider.clone(), + bidder: trace.bidder.clone(), + }); + let ext = (renderer.is_some() || bid_trace.is_some()) + .then(|| { + BidExt { + trusted_server: BidTrustedServerExt { + renderer, + trace: bid_trace, + }, + } + .to_ext() + }) + .flatten(); + let openrtb_bid = OpenRtbBid { id: bid .bid_id @@ -390,6 +421,14 @@ pub fn convert_to_openrtb_response( time_ms: result.total_time_ms, provider_details, }, + trusted_server: trace_enabled.then(|| TrustedServerResponseExt { + trace: AuctionTraceWire { + version: 1, + auction_trace_id: result.trace.summary.auction.auction_trace_id.to_string(), + source: result.trace.summary.auction.source.as_str(), + outcome: result.trace.summary.outcome.as_str(), + }, + }), } .to_ext(), ..Default::default() @@ -489,13 +528,14 @@ mod tests { } fn make_empty_result() -> OrchestrationResult { - OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 10, - metadata: HashMap::new(), - } + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new( + crate::auction::types::AuctionSource::AuctionApi, + ), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.total_time_ms = 10; + result } fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid { @@ -531,19 +571,34 @@ mod tests { } fn make_result(bid: Bid) -> OrchestrationResult { - OrchestrationResult { - provider_responses: vec![AuctionResponse { - provider: "prebid".to_string(), - bids: vec![bid.clone()], - status: BidStatus::Success, - response_time_ms: 42, - metadata: HashMap::new(), - }], - mediator_response: None, - winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), - total_time_ms: 50, + let mut result = make_empty_result(); + result.trace.summary.outcome = crate::auction::types::AuctionPublicOutcome::Completed; + result.trace.winning_bids.insert( + bid.slot_id.clone(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: bid.bidder.clone(), + }, + ); + result.winning_bid_origins.insert( + bid.slot_id.clone(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + ); + result.provider_responses = vec![AuctionResponse { + provider: "prebid".to_string(), + bids: vec![bid.clone()], + status: BidStatus::Success, + response_time_ms: 42, metadata: HashMap::new(), - } + }]; + result.winning_bids = HashMap::from([(bid.slot_id.clone(), bid)]); + result.total_time_ms = 50; + result } fn response_json(response: Response) -> JsonValue { @@ -1222,19 +1277,21 @@ mod tests { }] } }); - let result = OrchestrationResult { - provider_responses: vec![AuctionResponse { - provider: "aps".to_string(), - bids: vec![bid.clone()], - status: BidStatus::Success, - response_time_ms: 42, - metadata: HashMap::from([("debug".to_string(), debug.clone())]), - }], - mediator_response: None, - winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), - total_time_ms: 50, - metadata: HashMap::new(), - }; + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new( + crate::auction::types::AuctionSource::AuctionApi, + ), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.provider_responses = vec![AuctionResponse { + provider: "aps".to_string(), + bids: vec![bid.clone()], + status: BidStatus::Success, + response_time_ms: 42, + metadata: HashMap::from([("debug".to_string(), debug.clone())]), + }]; + result.winning_bids = HashMap::from([(bid.slot_id.clone(), bid)]); + result.total_time_ms = 50; let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) .expect("should convert APS response with debug metadata"); @@ -1331,17 +1388,69 @@ mod tests { ); } + #[test] + fn gated_response_adds_namespaced_root_and_winning_bid_trace() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response_with_trace( + &result, + &settings, + &auction_request, + false, + true, + ) + .expect("should convert traced response"); + let json = response_json(response); + + assert_eq!( + json["ext"]["trusted_server"]["trace"]["auction_trace_id"], + json!(result.trace.summary.auction.auction_trace_id.to_string()), + "should expose the shared trace identity" + ); + assert_eq!( + json["seatbid"][0]["bid"][0]["ext"]["trusted_server"]["trace"]["bid_trace_id"], + json!( + result.trace.winning_bids["div-gpt-top"] + .bid_trace_id + .to_string() + ), + "should expose only the final winner trace" + ); + assert_ne!( + json["ext"]["trusted_server"]["trace"]["auction_trace_id"], + json!(auction_request.id), + "should never expose the internal request ID as trace identity" + ); + } + + #[test] + fn ungated_response_omits_all_trace_extensions() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert legacy response"); + let json = response_json(response); + + assert!( + json["ext"].get("trusted_server").is_none(), + "ungated root should omit trace" + ); + assert!( + json["seatbid"][0]["bid"][0].get("ext").is_none(), + "ungated bid should omit trace" + ); + } + #[test] fn convert_to_openrtb_response_allows_empty_winning_bids() { let settings = make_settings(); let auction_request = make_auction_request(); - let result = OrchestrationResult { - provider_responses: vec![], - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 50, - metadata: HashMap::new(), - }; + let mut result = make_empty_result(); + result.total_time_ms = 50; let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) .expect("should convert auction result without winning bids"); @@ -1366,22 +1475,27 @@ mod tests { let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25)); sidebar_bid.creative = Some("
Sidebar
".to_string()); - let result = OrchestrationResult { - provider_responses: vec![AuctionResponse { - provider: "prebid".to_string(), - bids: vec![top_bid.clone(), sidebar_bid.clone()], - status: BidStatus::Success, - response_time_ms: 42, - metadata: HashMap::new(), - }], - mediator_response: None, - winning_bids: HashMap::from([ - (top_bid.slot_id.clone(), top_bid), - (sidebar_bid.slot_id.clone(), sidebar_bid), - ]), - total_time_ms: 50, - metadata: HashMap::new(), - }; + let mut result = make_result(top_bid.clone()); + result.provider_responses[0].bids.push(sidebar_bid.clone()); + result.trace.winning_bids.insert( + sidebar_bid.slot_id.clone(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: sidebar_bid.bidder.clone(), + }, + ); + result.winning_bid_origins.insert( + sidebar_bid.slot_id.clone(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 1, + mediated: false, + }, + ); + result + .winning_bids + .insert(sidebar_bid.slot_id.clone(), sidebar_bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) .expect("should convert multiple winning bids"); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index eb9b1d138..0dfa13008 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -14,7 +14,11 @@ use crate::platform::{PlatformPendingRequest, RuntimeServices}; use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::telemetry::AbandonedProviderCall; -use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +use super::types::{ + AuctionContext, AuctionPublicOutcome, AuctionRequest, AuctionResponse, AuctionResultTrace, + AuctionTraceContext, AuctionTraceSummary, Bid, BidStatus, BidTraceId, WinningBidOrigin, + WinningBidTrace, +}; /// In-flight auction requests dispatched to SSP backends. /// @@ -24,6 +28,7 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStat /// race in Fastly's native layer, enabling TTFB ≈ origin latency rather than /// TTFB ≈ auction timeout. pub struct DispatchedAuction { + trace: AuctionTraceContext, pending_requests: Vec, backend_to_provider: HashMap, u32)>, launch_responses: Vec, @@ -53,6 +58,12 @@ pub enum DispatchAuctionOutcome { } impl DispatchedAuction { + /// Return the trace context retained for split-phase collection. + #[must_use] + pub fn trace(&self) -> &AuctionTraceContext { + &self.trace + } + /// Consume the dispatch token without collecting provider responses. #[must_use] pub fn abandon( @@ -82,6 +93,7 @@ impl DispatchedAuction { impl DispatchedAuction { pub(crate) fn empty_for_test(request: AuctionRequest, timeout_ms: u32) -> Self { Self { + trace: AuctionTraceContext::new(super::types::AuctionSource::InitialNavigation), pending_requests: Vec::new(), backend_to_provider: HashMap::new(), launch_responses: Vec::new(), @@ -150,6 +162,39 @@ fn provider_transport_failed_response( .with_metadata("message", serde_json::json!("Provider request failed")) } +fn build_winning_bid_traces( + winning_bids: &HashMap, + origins: &HashMap, + provider_responses: &[AuctionResponse], + mediator_response: Option<&AuctionResponse>, + mut id_source: impl FnMut() -> BidTraceId, +) -> HashMap { + let mut traces = HashMap::with_capacity(winning_bids.len()); + for (slot_id, bid) in winning_bids { + let provider = origins + .get(slot_id) + .and_then(|origin| { + if origin.mediated { + mediator_response.map(|response| response.provider.clone()) + } else { + provider_responses + .get(origin.response_index) + .map(|response| response.provider.clone()) + } + }) + .unwrap_or_else(|| "unattributed".to_owned()); + traces.insert( + slot_id.clone(), + WinningBidTrace { + bid_trace_id: id_source(), + provider, + bidder: bid.bidder.clone(), + }, + ); + } + traces +} + fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> AuctionResponse { AuctionResponse::error(provider_name, response_time_ms) .with_metadata("error_type", serde_json::json!(ERROR_TYPE_TIMEOUT)) @@ -301,120 +346,103 @@ impl AuctionOrchestrator { let provider_responses = self.run_providers_parallel(request, context).await?; let floor_prices = self.floor_prices_by_slot(request); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { - let mediator = self.get_provider(mediator_name)?; + let (mediator_response, winning_bids, winning_bid_origins) = + if let Some(mediator_name) = &self.config.mediator { + let mediator = self.get_provider(mediator_name)?; + + log::info!( + "Sending {} provider responses to mediator: {}", + provider_responses.len(), + mediator.provider_name() + ); - log::info!( - "Sending {} provider responses to mediator: {}", - provider_responses.len(), - mediator.provider_name() - ); + // Give the mediator only the remaining time from the auction + // deadline, not the full timeout — the bidding phase already + // consumed part of it. Canonicalize the value for backend-name + // stability without exceeding the remaining budget. + let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + let mediator_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); + + if mediator_timeout == 0 { + log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&provider_responses, &floor_prices); + return Ok(self.finalize_result( + context.trace, + provider_responses, + None, + winning_bids, + winning_bid_origins, + 0, + )); + } - // Give the mediator only the remaining time from the auction - // deadline, not the full timeout — the bidding phase already - // consumed part of it, and the mediator has no select-loop - // deadline backstop. The platform canonicalizes the value for - // backend-name stability (see - // `PlatformBackend::canonicalize_transport_timeout_ms`); it never - // exceeds the remaining budget. See the transport-deadline note on - // `run_providers_parallel` for the limits of this bound. - let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - let mediator_timeout = context - .services - .backend() - .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); - - if mediator_timeout == 0 { - log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - return Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: 0, - metadata: HashMap::new(), - }); - } + let mediator_context = AuctionContext { + trace: context.trace, + settings: context.settings, + request: context.request, + timeout_ms: mediator_timeout, + provider_responses: Some(&provider_responses), + services: context.services, + }; - let mediator_context = AuctionContext { - settings: context.settings, - request: context.request, - timeout_ms: mediator_timeout, - provider_responses: Some(&provider_responses), - services: context.services, + let start_time = Instant::now(); + let pending = mediator + .request_bids(request, &mediator_context) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} failed to launch", mediator.provider_name()), + })?; + + let platform_resp = mediator_context + .services + .http_client() + .wait(pending) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} request failed", mediator.provider_name()), + })?; + + let response_time_ms = start_time.elapsed().as_millis() as u64; + // Use the context-aware parse so mediators (e.g. adserver_mock) can + // restore nurl/burl/ad_id and PBS cache fields from the collected SSP + // responses. The dispatched collect path already does this; the + // synchronous mediation path used by POST /auction and + // /__ts/page-bids must match or mediated cache bids lose the metadata + // needed for creative rendering and win/billing beacons. + let mediator_resp = mediator + .parse_response_with_context( + platform_resp, + response_time_ms, + request, + &mediator_context, + ) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} parse failed", mediator.provider_name()), + })?; + + let (winning_bids, winning_bid_origins) = + self.select_mediator_winning_bids(&mediator_resp, &floor_prices); + (Some(mediator_resp), winning_bids, winning_bid_origins) + } else { + // No mediator - select best bid per slot from bidder responses + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&provider_responses, &floor_prices); + (None, winning_bids, winning_bid_origins) }; - let start_time = Instant::now(); - let pending = mediator - .request_bids(request, &mediator_context) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} failed to launch", mediator.provider_name()), - })?; - - let platform_resp = mediator_context - .services - .http_client() - .wait(pending) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} request failed", mediator.provider_name()), - })?; - - let response_time_ms = start_time.elapsed().as_millis() as u64; - // Use the context-aware parse so mediators (e.g. adserver_mock) can - // restore nurl/burl/ad_id and PBS cache fields from the collected SSP - // responses. The dispatched collect path already does this; the - // synchronous mediation path used by POST /auction and - // /__ts/page-bids must match or mediated cache bids lose the metadata - // needed for creative rendering and win/billing beacons. - let mediator_resp = mediator - .parse_response_with_context( - platform_resp, - response_time_ms, - request, - &mediator_context, - ) - .await - .change_context(TrustedServerError::Auction { - message: format!("Mediator {} parse failed", mediator.provider_name()), - })?; - - // Extract only mediator bids with comparable numeric prices. - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without a price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - - ( - Some(mediator_resp), - self.apply_floor_prices(winning, &floor_prices), - ) - } else { - // No mediator - select best bid per slot from bidder responses - let winning = self.select_winning_bids(&provider_responses, &floor_prices); - (None, winning) - }; - - Ok(OrchestrationResult { + Ok(self.finalize_result( + context.trace, provider_responses, mediator_response, winning_bids, - total_time_ms: 0, // Will be set by caller - metadata: HashMap::new(), - }) + winning_bid_origins, + 0, + )) } /// Run auction with only parallel bidding (no mediation). @@ -425,15 +453,17 @@ impl AuctionOrchestrator { ) -> Result> { let provider_responses = self.run_providers_parallel(request, context).await?; let floor_prices = self.floor_prices_by_slot(request); - let winning_bids = self.select_winning_bids(&provider_responses, &floor_prices); + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&provider_responses, &floor_prices); - Ok(OrchestrationResult { + Ok(self.finalize_result( + context.trace, provider_responses, - mediator_response: None, + None, winning_bids, - total_time_ms: 0, - metadata: HashMap::new(), - }) + winning_bid_origins, + 0, + )) } /// Run all providers in parallel and collect responses. @@ -549,6 +579,7 @@ impl AuctionOrchestrator { } let provider_context = AuctionContext { + trace: context.trace, settings: context.settings, request: context.request, timeout_ms: effective_timeout, @@ -693,6 +724,7 @@ impl AuctionOrchestrator { { let response_time_ms = start_time.elapsed().as_millis() as u64; let provider_context = AuctionContext { + trace: context.trace, settings: context.settings, request: context.request, timeout_ms: effective_timeout, @@ -794,20 +826,24 @@ impl AuctionOrchestrator { Ok(responses) } - /// Select the best decoded-price bid for each slot from all responses. + /// Select the best decoded-price bid for each slot while retaining its exact origin. + /// + /// Bids with no decoded price (for example, encoded APS bids) are skipped when + /// no mediator is configured because they cannot be compared. fn select_winning_bids( &self, responses: &[AuctionResponse], floor_prices: &HashMap, - ) -> HashMap { + ) -> (HashMap, HashMap) { let mut winning_bids: HashMap = HashMap::new(); + let mut origins = HashMap::new(); - for response in responses { + for (response_index, response) in responses.iter().enumerate() { if response.status != BidStatus::Success { continue; } - for bid in &response.bids { + for (bid_index, bid) in response.bids.iter().enumerate() { let bid_price = match bid.price { Some(p) => p, None => { @@ -828,12 +864,91 @@ impl AuctionOrchestrator { }; if should_replace { + origins.insert( + bid.slot_id.clone(), + WinningBidOrigin { + response_index, + bid_index, + mediated: false, + }, + ); winning_bids.insert(bid.slot_id.clone(), bid.clone()); } } } - self.apply_floor_prices(winning_bids, floor_prices) + let winning_bids = self.apply_floor_prices(winning_bids, floor_prices); + origins.retain(|slot_id, _| winning_bids.contains_key(slot_id)); + (winning_bids, origins) + } + + fn select_mediator_winning_bids( + &self, + response: &AuctionResponse, + floor_prices: &HashMap, + ) -> (HashMap, HashMap) { + let mut winning_bids = HashMap::new(); + let mut origins = HashMap::new(); + for (bid_index, bid) in response.bids.iter().enumerate() { + if bid.price.is_none() { + log::warn!( + "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", + response.provider, + bid.slot_id + ); + continue; + } + origins.insert( + bid.slot_id.clone(), + WinningBidOrigin { + response_index: 0, + bid_index, + mediated: true, + }, + ); + winning_bids.insert(bid.slot_id.clone(), bid.clone()); + } + let winning_bids = self.apply_floor_prices(winning_bids, floor_prices); + origins.retain(|slot_id, _| winning_bids.contains_key(slot_id)); + (winning_bids, origins) + } + + fn finalize_result( + &self, + trace: &AuctionTraceContext, + provider_responses: Vec, + mediator_response: Option, + winning_bids: HashMap, + winning_bid_origins: HashMap, + total_time_ms: u64, + ) -> OrchestrationResult { + let outcome = if winning_bids.is_empty() { + AuctionPublicOutcome::NoBid + } else { + AuctionPublicOutcome::Completed + }; + let trace_bids = build_winning_bid_traces( + &winning_bids, + &winning_bid_origins, + &provider_responses, + mediator_response.as_ref(), + BidTraceId::new, + ); + OrchestrationResult { + trace: AuctionResultTrace { + summary: AuctionTraceSummary { + auction: trace.clone(), + outcome, + }, + winning_bids: trace_bids, + }, + winning_bid_origins, + provider_responses, + mediator_response, + winning_bids, + total_time_ms, + metadata: HashMap::new(), + } } fn apply_floor_prices( @@ -1017,6 +1132,7 @@ impl AuctionOrchestrator { } let provider_context = AuctionContext { + trace: context.trace, settings: context.settings, request: context.request, timeout_ms: effective_timeout, @@ -1096,6 +1212,7 @@ impl AuctionOrchestrator { ); DispatchAuctionOutcome::Dispatched(DispatchedAuction { + trace: context.trace.clone(), pending_requests, backend_to_provider, launch_responses, @@ -1124,6 +1241,7 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> OrchestrationResult { let DispatchedAuction { + trace, pending_requests, mut backend_to_provider, launch_responses, @@ -1175,6 +1293,7 @@ impl AuctionOrchestrator { { let response_time_ms = start_time.elapsed().as_millis() as u64; let provider_context = AuctionContext { + trace: context.trace, settings: context.settings, request: &provider_request_context, timeout_ms: effective_timeout, @@ -1268,7 +1387,7 @@ impl AuctionOrchestrator { } backend_to_provider.clear(); - let (mediator_response, winning_bids) = if let Some(mediator_name) = &self.config.mediator { + let (mediator_response, selection) = if let Some(mediator_name) = &self.config.mediator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { // Cap the mediator at whichever is tighter: its own configured @@ -1299,14 +1418,16 @@ impl AuctionOrchestrator { mediator.provider_name(), responses.len(), ); - let winning = self.select_winning_bids(&responses, &floor_prices); - return OrchestrationResult { - provider_responses: responses, - mediator_response: None, - winning_bids: winning, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - }; + let (winning_bids, winning_bid_origins) = + self.select_winning_bids(&responses, &floor_prices); + return self.finalize_result( + &trace, + responses, + None, + winning_bids, + winning_bid_origins, + auction_start.elapsed().as_millis() as u64, + ); } let mediator_start = Instant::now(); log::info!( @@ -1327,6 +1448,7 @@ impl AuctionOrchestrator { .body(edgezero_core::body::Body::empty()) .unwrap_or_else(|_| http::Request::new(edgezero_core::body::Body::empty())); let mediator_context = AuctionContext { + trace: &trace, settings: context.settings, request: &placeholder, timeout_ms: mediator_timeout, @@ -1358,25 +1480,11 @@ impl AuctionOrchestrator { .await { Ok(mediator_resp) => { - let winning = mediator_resp - .bids - .iter() - .filter_map(|bid| { - if bid.price.is_none() { - log::warn!( - "Mediator '{}' returned bid for slot '{}' without decoded price - skipping", - mediator.provider_name(), - bid.slot_id - ); - None - } else { - Some((bid.slot_id.clone(), bid.clone())) - } - }) - .collect(); - let winning = - self.apply_floor_prices(winning, &floor_prices); - (Some(mediator_resp), winning) + let selection = self.select_mediator_winning_bids( + &mediator_resp, + &floor_prices, + ); + (Some(mediator_resp), selection) } Err(e) => { log::warn!( @@ -1417,13 +1525,15 @@ impl AuctionOrchestrator { (None, self.select_winning_bids(&responses, &floor_prices)) }; - OrchestrationResult { - provider_responses: responses, + let (winning_bids, winning_bid_origins) = selection; + self.finalize_result( + &trace, + responses, mediator_response, winning_bids, - total_time_ms: auction_start.elapsed().as_millis() as u64, - metadata: HashMap::new(), - } + winning_bid_origins, + auction_start.elapsed().as_millis() as u64, + ) } /// Check if orchestrator is enabled. @@ -1436,6 +1546,10 @@ impl AuctionOrchestrator { /// Result of an orchestrated auction. #[derive(Debug, Clone)] pub struct OrchestrationResult { + /// Privacy-safe tester trace for this finalized result. + pub trace: AuctionResultTrace, + /// Exact internal origin of each final winning bid. + pub(crate) winning_bid_origins: HashMap, /// All responses from providers pub provider_responses: Vec, /// Final response from mediator (if used) @@ -1449,6 +1563,32 @@ pub struct OrchestrationResult { } impl OrchestrationResult { + /// Build a no-bid result for a terminal path that already returns a response. + #[must_use] + pub fn empty(trace: AuctionTraceContext, outcome: AuctionPublicOutcome) -> Self { + Self { + trace: AuctionResultTrace { + summary: AuctionTraceSummary { + auction: trace, + outcome, + }, + winning_bids: HashMap::new(), + }, + winning_bid_origins: HashMap::new(), + provider_responses: Vec::new(), + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 0, + metadata: HashMap::new(), + } + } + + /// Return the exact provider/bid location for a final winning slot. + #[must_use] + pub(crate) fn winning_origin(&self, slot_id: &str) -> Option { + self.winning_bid_origins.get(slot_id).copied() + } + /// Get the winning bid for a specific slot. #[must_use] pub fn get_winning_bid(&self, slot_id: &str) -> Option<&Bid> { @@ -1483,7 +1623,8 @@ mod tests { use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, + AuctionResponse, Bid, BidRenderer, BidStatus, BidTraceId, MediaType, PublisherInfo, + UserInfo, WinningBidOrigin, }; use crate::error::TrustedServerError; use crate::platform::test_support::{ @@ -1499,7 +1640,7 @@ mod tests { use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; - use super::AuctionOrchestrator; + use super::{AuctionOrchestrator, build_winning_bid_traces}; // --------------------------------------------------------------------------- // Minimal test double for AuctionProvider @@ -1694,6 +1835,62 @@ mod tests { } } + #[test] + fn mediated_selection_retains_the_mediator_response_origin() { + let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); + let selected = mediated_bid(None); + let mediator_response = AuctionResponse::success("mediator", vec![selected], 1); + + let (_, origins) = + orchestrator.select_mediator_winning_bids(&mediator_response, &HashMap::new()); + + let origin = origins["header-banner"]; + assert!( + origin.mediated, + "mediated selection should retain mediator origin" + ); + assert_eq!( + origin.bid_index, 0, + "should retain exact mediator bid index" + ); + } + + #[test] + fn winning_trace_builder_uses_supplied_id_source_only_for_final_winners() { + let mut winner = mediated_bid(None); + winner.bidder = "example-bidder".to_owned(); + let provider_responses = vec![AuctionResponse::success( + "provider-a", + vec![winner.clone()], + 1, + )]; + let winning_bids = HashMap::from([("header-banner".to_owned(), winner)]); + let origins = HashMap::from([( + "header-banner".to_owned(), + WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + )]); + let fixed = uuid::Uuid::parse_str("650e8400-e29b-41d4-a716-446655440000") + .expect("should parse fixed UUID"); + let mut calls = 0; + + let traces = + build_winning_bid_traces(&winning_bids, &origins, &provider_responses, None, || { + calls += 1; + BidTraceId::from_uuid(fixed) + }); + + assert_eq!(calls, 1, "should allocate one ID for one final winner"); + assert_eq!( + traces["header-banner"].bid_trace_id.to_string(), + fixed.to_string(), + "should use the supplied deterministic ID" + ); + } + #[async_trait::async_trait(?Send)] impl AuctionProvider for CacheRestoringMediator { fn provider_name(&self) -> &'static str { @@ -1795,6 +1992,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2317,6 +2515,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2395,6 +2594,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2456,6 +2656,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2538,6 +2739,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2635,6 +2837,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2712,6 +2915,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2781,6 +2985,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2850,6 +3055,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build downstream request"); let dispatch_context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &downstream, timeout_ms: 750, @@ -2868,6 +3074,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build placeholder request"); let collect_context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &placeholder, timeout_ms: 750, @@ -2933,6 +3140,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -2998,6 +3206,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &req, timeout_ms: 2000, @@ -3026,7 +3235,7 @@ mod tests { let floor_prices = HashMap::new(); let response = |provider: &str, bid: Bid| AuctionResponse::success(provider, vec![bid], 1); - let aps_wins = orchestrator.select_winning_bids( + let (aps_wins, _) = orchestrator.select_winning_bids( &[ response("aps", auction_bid("aps", 2.0)), response("ordinary", auction_bid("ordinary", 1.0)), @@ -3038,7 +3247,7 @@ mod tests { assert!(winner.renderer.is_some()); assert!(winner.creative.is_none()); - let ordinary_wins = orchestrator.select_winning_bids( + let (ordinary_wins, _) = orchestrator.select_winning_bids( &[ response("aps", auction_bid("aps", 2.0)), response("ordinary", auction_bid("ordinary", 3.0)), diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index c252f5351..34e80b8e0 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -3,7 +3,6 @@ //! Core owns the privacy-preserving auction observation model and pure row //! builder. Platform adapters provide the concrete sink implementation. -use std::collections::HashSet; use std::time::Instant; use chrono::Utc; @@ -12,7 +11,10 @@ use serde::Serialize; use uuid::Uuid; use crate::auction::orchestrator::OrchestrationResult; -use crate::auction::types::{AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType}; +pub use crate::auction::types::AuctionSource; +use crate::auction::types::{ + AuctionRequest, AuctionResponse, AuctionTraceContext, Bid, BidStatus, MediaType, +}; use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::platform::RuntimeServices; @@ -20,27 +22,6 @@ use crate::platform::RuntimeServices; const MAX_PAGE_PATH_BYTES: usize = 256; const DYNAMIC_SEGMENT_REPLACEMENT: &str = ":id"; -/// Source path that initiated an auction candidate. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum AuctionSource { - /// Initial publisher navigation using server-side ad templates. - InitialNavigation, - /// SPA navigation through `GET /__ts/page-bids`. - SpaNavigation, - /// Explicit `POST /auction` API. - AuctionApi, -} - -impl AuctionSource { - fn as_str(self) -> &'static str { - match self { - Self::InitialNavigation => "initial_navigation", - Self::SpaNavigation => "spa_navigation", - Self::AuctionApi => "auction_api", - } - } -} - /// Terminal status for one auction observation. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum AuctionTerminalStatus { @@ -123,7 +104,7 @@ impl AuctionObservationContext { /// Build an observation context from an auction request. #[must_use] pub fn from_auction_request( - auction_source: AuctionSource, + trace: &AuctionTraceContext, request: &AuctionRequest, ec_context: &EcContext, ) -> Self { @@ -135,7 +116,7 @@ impl AuctionObservationContext { .map(|url| url.path().to_owned()) .unwrap_or_else(|| "/".to_owned()); Self::from_parts( - auction_source, + trace, &request.publisher.domain, &raw_path, request.slots.len(), @@ -146,7 +127,7 @@ impl AuctionObservationContext { /// Build an observation context from publisher request parts. #[must_use] pub fn from_parts( - auction_source: AuctionSource, + trace: &AuctionTraceContext, publisher_domain: &str, raw_page_path: &str, slot_count: usize, @@ -157,8 +138,8 @@ impl AuctionObservationContext { let consent = ec_context.consent(); let slot_count = u16::try_from(slot_count).unwrap_or(u16::MAX); Self { - auction_id: Uuid::new_v4(), - auction_source, + auction_id: trace.auction_trace_id.as_uuid(), + auction_source: trace.source, publisher_domain: publisher_domain.to_owned(), page_path: normalize_page_path(raw_page_path), country: geo @@ -264,7 +245,7 @@ pub struct AuctionEventRow { pub event_ts: String, /// `summary`, `provider_call`, or `bid`. pub event_kind: String, - /// Fresh telemetry auction UUID. + /// Privacy-safe UUID shared with tester-gated trace output. pub auction_id: String, /// Source path label. pub auction_source: String, @@ -320,6 +301,8 @@ pub struct AuctionEventRow { pub currency: Option, /// Whether this is the canonical winning row for its slot. pub is_win: Option, + /// Trace UUID for the canonical winning bid only. + pub bid_trace_id: Option, /// Advertiser domain. pub ad_domain: Option, /// Creative/ad ID. @@ -359,6 +342,7 @@ impl AuctionEventRow { price_cpm: None, currency: None, is_win: None, + bid_trace_id: None, ad_domain: None, ad_id: None, } @@ -683,68 +667,84 @@ fn push_bid_rows( request: &AuctionRequest, result: &OrchestrationResult, ) { - let mut matched_wins = HashSet::new(); - - for response in &result.provider_responses { - for bid in &response.bids { - let matched_slot = result - .winning_bids - .iter() - .find(|(slot_id, winning)| { - !matched_wins.contains(*slot_id) && bid_matches_winning_bid(bid, winning) + for (response_index, response) in result.provider_responses.iter().enumerate() { + for (bid_index, bid) in response.bids.iter().enumerate() { + let winning_slot = result.winning_bids.keys().find(|slot_id| { + result.winning_origin(slot_id).is_some_and(|origin| { + !origin.mediated + && origin.response_index == response_index + && origin.bid_index == bid_index }) - .map(|(slot_id, winning)| (slot_id.clone(), winning)); - let (is_win, price) = if let Some((slot_id, winning)) = matched_slot { - matched_wins.insert(slot_id); - (1, bid.price.or(winning.price)) - } else { - (0, bid.price) - }; + }); + let trace_id = winning_slot.and_then(|slot_id| { + result + .trace + .winning_bids + .get(slot_id) + .map(|trace| trace.bid_trace_id.to_string()) + }); + let price = winning_slot + .and_then(|slot_id| result.winning_bids.get(slot_id)) + .and_then(|winning| winning.price) + .or(bid.price); rows.push(bid_row( observation, event_ts, request, &response.provider, bid, - is_win, - price, + BidRowOutcome { + is_win: u8::from(winning_slot.is_some()), + price, + bid_trace_id: trace_id, + }, )); } } if let Some(mediator_response) = &result.mediator_response { - for (slot_id, winning) in &result.winning_bids { - if matched_wins.contains(slot_id) { - continue; - } - if mediator_response - .bids - .iter() - .any(|bid| bid_matches_winning_bid(bid, winning)) - { + for (bid_index, bid) in mediator_response.bids.iter().enumerate() { + let winning_slot = result.winning_bids.keys().find(|slot_id| { + result + .winning_origin(slot_id) + .is_some_and(|origin| origin.mediated && origin.bid_index == bid_index) + }); + if let Some(slot_id) = winning_slot { + let trace_id = result + .trace + .winning_bids + .get(slot_id) + .map(|trace| trace.bid_trace_id.to_string()); rows.push(bid_row( observation, event_ts, request, &mediator_response.provider, - winning, - 1, - winning.price, + bid, + BidRowOutcome { + is_win: 1, + price: bid.price, + bid_trace_id: trace_id, + }, )); - matched_wins.insert(slot_id.clone()); } } } } +struct BidRowOutcome { + is_win: u8, + price: Option, + bid_trace_id: Option, +} + fn bid_row( observation: &AuctionObservationContext, event_ts: &str, request: &AuctionRequest, provider: &str, bid: &Bid, - is_win: u8, - price: Option, + outcome: BidRowOutcome, ) -> AuctionEventRow { let mut row = AuctionEventRow::base(observation, "bid", event_ts); row.provider = Some(provider.to_owned()); @@ -753,9 +753,10 @@ fn bid_row( row.slot_h = Some(u16::try_from(bid.height).unwrap_or(u16::MAX)); row.media_type = media_type_for_slot(request, &bid.slot_id).map(str::to_owned); row.seat = Some(bid.bidder.clone()); - row.price_cpm = price; + row.price_cpm = outcome.price; row.currency = Some(bid.currency.clone()); - row.is_win = Some(is_win); + row.is_win = Some(outcome.is_win); + row.bid_trace_id = outcome.bid_trace_id; row.ad_domain = bid .adomain .as_ref() @@ -764,16 +765,6 @@ fn bid_row( row } -fn bid_matches_winning_bid(candidate: &Bid, winning: &Bid) -> bool { - if candidate.slot_id != winning.slot_id || candidate.bidder != winning.bidder { - return false; - } - match winning.ad_id.as_deref() { - Some(winning_ad_id) => candidate.ad_id.as_deref() == Some(winning_ad_id), - None => true, - } -} - fn media_type_for_slot<'a>(request: &'a AuctionRequest, slot_id: &str) -> Option<&'a str> { request .slots @@ -948,6 +939,15 @@ mod tests { } } + fn empty_result(total_time_ms: u64) -> OrchestrationResult { + let mut result = OrchestrationResult::empty( + AuctionTraceContext::new(AuctionSource::AuctionApi), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.total_time_ms = total_time_ms; + result + } + fn bid(slot_id: &str, bidder: &str, ad_id: Option<&str>, price: Option) -> Bid { Bid { slot_id: slot_id.to_owned(), @@ -1027,13 +1027,27 @@ mod tests { let provider_error = AuctionResponse::error("mock", 12).with_metadata("error_type", json!("parse_response")); let winning = provider_success.bids[0].clone(); - let result = OrchestrationResult { - provider_responses: vec![provider_success, provider_no_bid, provider_error], - mediator_response: None, - winning_bids: HashMap::from([("slot-1".to_owned(), winning)]), - total_time_ms: 99, - metadata: HashMap::new(), - }; + let mut result = empty_result(99); + result.provider_responses = vec![provider_success, provider_no_bid, provider_error]; + result + .winning_bids + .insert("slot-1".to_owned(), winning.clone()); + result.winning_bid_origins.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + ); + result.trace.winning_bids.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: winning.bidder, + }, + ); let observation = AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/article/1", 1); @@ -1091,13 +1105,8 @@ mod tests { let provider_http_error = AuctionResponse::error("prebid", 12) .with_metadata("error_type", json!("http_status")) .with_metadata("status", json!(403)); - let result = OrchestrationResult { - provider_responses: vec![provider_http_error], - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 12, - metadata: HashMap::new(), - }; + let mut result = empty_result(12); + result.provider_responses = vec![provider_http_error]; let observation = AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/article/1", 1); @@ -1128,13 +1137,28 @@ mod tests { let mediator_bid = bid("slot-1", "kargo", Some("ad-1"), Some(2.0)); let mediator_response = AuctionResponse::success("adserver_mock", vec![mediator_bid.clone()], 15); - let result = OrchestrationResult { - provider_responses: vec![provider_success], - mediator_response: Some(mediator_response), - winning_bids: HashMap::from([("slot-1".to_owned(), mediator_bid)]), - total_time_ms: 80, - metadata: HashMap::new(), - }; + let mut result = empty_result(80); + result.provider_responses = vec![provider_success]; + result.mediator_response = Some(mediator_response); + result + .winning_bids + .insert("slot-1".to_owned(), mediator_bid.clone()); + result.winning_bid_origins.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidOrigin { + response_index: 0, + bid_index: 0, + mediated: false, + }, + ); + result.trace.winning_bids.insert( + "slot-1".to_owned(), + crate::auction::types::WinningBidTrace { + bid_trace_id: crate::auction::types::BidTraceId::new(), + provider: "prebid".to_owned(), + bidder: mediator_bid.bidder, + }, + ); let observation = AuctionObservationContext::for_test(AuctionSource::InitialNavigation, "/", 1); @@ -1167,13 +1191,7 @@ mod tests { #[test] fn ndjson_serialization_has_one_json_object_per_line_and_no_private_ids() { let request = test_request("ts-ec-derived-id"); - let result = OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 1, - metadata: HashMap::new(), - }; + let result = empty_result(1); let observation = AuctionObservationContext::for_test(AuctionSource::AuctionApi, "/auction", 1); diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index e4b953e05..45d90731e 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -3,11 +3,18 @@ use std::sync::LazyLock; use edgezero_core::body::Body as EdgeBody; use http::Request; -use super::AuctionContext; +use super::{AuctionContext, AuctionSource}; +use crate::auction::types::AuctionTraceContext; use crate::platform::{RuntimeServices, test_support::noop_services}; use crate::settings::Settings; static TEST_SERVICES: LazyLock = LazyLock::new(noop_services); +static TEST_TRACE: LazyLock = + LazyLock::new(|| AuctionTraceContext::new(AuctionSource::AuctionApi)); + +pub(crate) fn test_trace() -> &'static AuctionTraceContext { + &TEST_TRACE +} pub(crate) fn create_test_auction_context<'a>( settings: &'a Settings, @@ -16,6 +23,7 @@ pub(crate) fn create_test_auction_context<'a>( ) -> AuctionContext<'a> { let services: &'static RuntimeServices = &TEST_SERVICES; AuctionContext { + trace: test_trace(), settings, request, timeout_ms, diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index a6ad61f3a..e101a7245 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -4,12 +4,157 @@ use edgezero_core::body::Body as EdgeBody; use http::Request; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use uuid::Uuid; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; +/// Source path that initiated an auction candidate. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionSource { + /// Initial publisher navigation using server-side ad templates. + InitialNavigation, + /// SPA navigation through `GET /__ts/page-bids`. + SpaNavigation, + /// Explicit `POST /auction` API. + AuctionApi, +} + +impl AuctionSource { + /// Return the stable wire label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::InitialNavigation => "initial_navigation", + Self::SpaNavigation => "spa_navigation", + Self::AuctionApi => "auction_api", + } + } +} + +/// Privacy-safe public identity for one auction candidate. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, derive_more::Display)] +pub struct AuctionTraceId(Uuid); + +impl AuctionTraceId { + /// Generate a fresh random trace identity. + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + /// Return the underlying UUID. + #[must_use] + pub const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for AuctionTraceId { + fn default() -> Self { + Self::new() + } +} + +/// Privacy-safe public identity for one final winning bid. +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, derive_more::Display)] +pub struct BidTraceId(Uuid); + +impl BidTraceId { + /// Generate a fresh random trace identity. + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + #[cfg(test)] + pub(crate) const fn from_uuid(value: Uuid) -> Self { + Self(value) + } +} + +impl Default for BidTraceId { + fn default() -> Self { + Self::new() + } +} + +/// Trace identity and source shared throughout one auction lifecycle. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AuctionTraceContext { + pub auction_trace_id: AuctionTraceId, + pub source: AuctionSource, +} + +impl AuctionTraceContext { + /// Generate a context for an auction candidate. + #[must_use] + pub fn new(source: AuctionSource) -> Self { + Self { + auction_trace_id: AuctionTraceId::new(), + source, + } + } +} + +/// Privacy-safe terminal state exposed to tester traffic. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionPublicOutcome { + Completed, + NoBid, + Skipped, + Failed, + Abandoned, +} + +impl AuctionPublicOutcome { + /// Return the stable wire label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::NoBid => "no_bid", + Self::Skipped => "skipped", + Self::Failed => "failed", + Self::Abandoned => "abandoned", + } + } +} + +/// Result-independent public summary for one auction candidate. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AuctionTraceSummary { + pub auction: AuctionTraceContext, + pub outcome: AuctionPublicOutcome, +} + +/// Public trace metadata for one final winning bid. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct WinningBidTrace { + pub bid_trace_id: BidTraceId, + pub provider: String, + pub bidder: String, +} + +/// Trace data attached to a finalized auction result. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct AuctionResultTrace { + pub summary: AuctionTraceSummary, + pub winning_bids: HashMap, +} + +/// Exact internal location of a final winning bid. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct WinningBidOrigin { + pub response_index: usize, + pub bid_index: usize, + pub mediated: bool, +} + /// Represents a unified auction request across all providers. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuctionRequest { @@ -140,6 +285,8 @@ pub struct SiteInfo { /// [dispatch]: crate::auction::AuctionOrchestrator::dispatch_auction /// [collect]: crate::auction::AuctionOrchestrator::collect_dispatched_auction pub struct AuctionContext<'a> { + /// Trace identity owned by the auction entry point. + pub trace: &'a AuctionTraceContext, pub settings: &'a Settings, pub request: &'a Request, pub timeout_ms: u32, diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd747..e5a59cda0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -16,16 +16,18 @@ use validator::{Validate, ValidationError, ValidationErrors}; use crate::ec::registry::PartnerRegistry; 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, + ad_trace::AdTraceConfig, 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, }; use crate::settings::{IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ + "ad_trace", "prebid", "aps", "adserver_mock", @@ -136,6 +138,7 @@ fn validate_enabled_integrations( ) -> Result, Report> { let mut enabled_auction_providers = HashSet::new(); + validate_integration::(settings, "ad_trace")?; if validate_prebid(settings)? { enabled_auction_providers.insert("prebid"); } diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index ffcf4f034..03b5b6d24 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,10 @@ pub const COOKIE_TS_EC: &str = "ts-ec"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +/// Host-only browser-session cookie activated by the ad trace console query. +pub const COOKIE_TS_CONSOLE: &str = "__Host-ts-console"; +/// Reserved self-service query parameter for the ad trace console. +pub const QUERY_TS_CONSOLE: &str = "ts_console"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index ad69e51c5..9ef9ee0fd 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -161,6 +161,8 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, + /// Request-scoped console bootstrap injected before the unified bundle. + pub head_bootstrap_script: Option, /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -189,6 +191,7 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + head_bootstrap_script: None, 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, @@ -205,9 +208,11 @@ impl HtmlProcessorConfig { #[must_use] pub fn with_ad_state( mut self, + head_bootstrap_script: Option, ad_slots_script: Option, ad_bids_state: std::sync::Arc>>, ) -> Self { + self.head_bootstrap_script = head_bootstrap_script; self.ad_slots_script = ad_slots_script; self.ad_bids_state = ad_bids_state; self @@ -292,6 +297,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); + let head_bootstrap_script = config.head_bootstrap_script.clone(); let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); @@ -302,10 +308,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integrations = integration_registry.clone(); let patterns = patterns.clone(); let document_state = document_state.clone(); + let head_bootstrap_script = head_bootstrap_script.clone(); let ad_slots_script = ad_slots_script.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // Request-scoped activation must run before every TSJS module. + if let Some(ref bootstrap) = head_bootstrap_script { + snippet.push_str(bootstrap); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -661,6 +672,7 @@ mod tests { request_host: "test.example.com".to_owned(), request_scheme: "https".to_owned(), integrations: IntegrationRegistry::default(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, @@ -738,6 +750,8 @@ mod tests { let html = "Test"; let mut config = create_test_config(); + config.head_bootstrap_script = + Some("".to_owned()); config.integrations = IntegrationRegistry::from_rewriters_with_head_injectors( Vec::new(), Vec::new(), @@ -759,6 +773,7 @@ mod tests { let processed = String::from_utf8(output).expect("output should be valid UTF-8"); let tsjs_marker = "id=\"trustedserver-js\""; + let bootstrap_marker = "window.__tsjs_adTraceActive=true"; let head_marker = "window.__testHeadInjector=true"; assert_eq!( @@ -775,6 +790,9 @@ mod tests { let tsjs_index = processed .find(tsjs_marker) .expect("should include unified tsjs tag"); + let bootstrap_index = processed + .find(bootstrap_marker) + .expect("should include request bootstrap"); let head_index = processed .find(head_marker) .expect("should include head snippet"); @@ -783,8 +801,8 @@ mod tests { .expect("should keep existing head content"); assert!( - head_index < tsjs_index, - "should inject config before tsjs bundle so auto-init can read it" + bootstrap_index < head_index && head_index < tsjs_index, + "should inject request bootstrap and config before tsjs auto-init" ); assert!( tsjs_index < title_index, @@ -1430,6 +1448,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""# .to_string(), @@ -1504,6 +1523,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""#.to_string(), ), @@ -1539,6 +1559,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""#.to_string(), ), @@ -1575,6 +1596,7 @@ mod tests { request_host: request_host.to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, @@ -1625,6 +1647,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: Some( r#""#.to_string(), ), @@ -1653,6 +1676,7 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/integrations/ad_trace.rs b/crates/trusted-server-core/src/integrations/ad_trace.rs new file mode 100644 index 000000000..41099d4aa --- /dev/null +++ b/crates/trusted-server-core/src/integrations/ad_trace.rs @@ -0,0 +1,620 @@ +//! Query-activated, session-scoped auction trace integration. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use http::{HeaderValue, Method, Request, Response, Uri, header, uri::PathAndQuery}; +use serde::Deserialize; +use validator::Validate; + +use crate::constants::{COOKIE_TS_CONSOLE, QUERY_TS_CONSOLE}; +use crate::error::TrustedServerError; +use crate::http_util::is_navigation_request; +use crate::integrations::IntegrationRegistration; +use crate::settings::{IntegrationConfig, Settings}; + +/// Stable integration identifier. +pub const AD_TRACE_INTEGRATION_ID: &str = "ad_trace"; + +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 optional browser console. +#[derive(Debug, Default, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct AdTraceConfig { + /// Enable the optional ad trace browser module and console activation. + #[serde(default)] + pub enabled: bool, +} + +impl IntegrationConfig for AdTraceConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// Cookie mutation attached to an eligible console-navigation response. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ConsoleCookieAction { + #[default] + None, + SetSession, + ClearSession, +} + +/// Immutable request-scoped console decision. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AdTraceRequestDecision { + enabled: bool, + browser_bootstrap: bool, + private_response: bool, + clean_browser_path_and_query: Option, + cookie_action: ConsoleCookieAction, +} + +impl AdTraceRequestDecision { + /// Whether browser-visible trace fields and targeting are enabled. + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } + + /// Whether this response must be private and non-storeable. + #[must_use] + pub fn requires_private_no_store(&self) -> bool { + self.private_response + || self.cookie_action != ConsoleCookieAction::None + || self.clean_browser_path_and_query.is_some() + } + + /// Build the synchronous bootstrap inserted before the unified TSJS bundle. + #[must_use] + pub fn bootstrap_script(&self) -> Option { + if !self.browser_bootstrap && self.clean_browser_path_and_query.is_none() { + return None; + } + + let mut script = String::from(""); + Some(script) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueryDirective { + Absent, + Enable, + Disable, + Invalid, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ConsoleCookieState { + occurrences: usize, + canonical: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +struct AdTraceCookieApplied; + +/// Register the optional browser module. +/// +/// # Errors +/// +/// Returns a configuration error when the integration settings are invalid. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(_config) = settings.integration_config::(AD_TRACE_INTEGRATION_ID)? + else { + return Ok(None); + }; + Ok(Some( + IntegrationRegistration::builder(AD_TRACE_INTEGRATION_ID).build(), + )) +} + +/// Evaluate and sanitize the console request before routing or downstream use. +/// +/// The original query and cookie are inspected first. Every reserved query pair +/// and console cookie is then removed from the request. The immutable decision +/// is stored in request extensions for handlers to consume after sanitation. +/// +/// # Errors +/// +/// Returns an error when integration configuration or URI reconstruction fails. +pub fn prepare_request( + settings: &Settings, + request: &mut Request, +) -> Result> { + let integration_enabled = settings + .integration_config::(AD_TRACE_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 = is_eligible_console_navigation(request); + + sanitize_console_cookie(request); + if had_reserved_query { + replace_path_and_query(request, &clean_path)?; + } + + let mut decision = AdTraceRequestDecision::default(); + if integration_enabled && eligible_navigation && had_reserved_query { + decision.clean_browser_path_and_query = Some(clean_path); + match directive { + QueryDirective::Enable => { + decision.enabled = true; + decision.browser_bootstrap = true; + decision.cookie_action = ConsoleCookieAction::SetSession; + } + QueryDirective::Disable => { + decision.cookie_action = ConsoleCookieAction::ClearSession; + } + QueryDirective::Invalid | QueryDirective::Absent => {} + } + } else if integration_enabled + && directive == QueryDirective::Absent + && cookie_state.occurrences == 1 + && cookie_state.canonical + { + decision.enabled = true; + decision.browser_bootstrap = eligible_navigation; + } + + decision.private_response = + decision.enabled && trace_payload_request(request, eligible_navigation); + request.extensions_mut().insert(decision.clone()); + Ok(decision) +} + +/// Read the previously prepared request decision. +#[must_use] +pub fn request_decision(request: &Request) -> AdTraceRequestDecision { + request + .extensions() + .get::() + .cloned() + .unwrap_or_default() +} + +/// Return whether browser-visible trace output is active for this request. +#[must_use] +pub fn browser_trace_enabled(request: &Request) -> bool { + request_decision(request).enabled() +} + +/// Copy the prepared request decision onto a response for outer finalization. +pub fn attach_response_decision( + decision: &AdTraceRequestDecision, + response: &mut Response, +) { + response.extensions_mut().insert(decision.clone()); +} + +/// Apply the response-side session mutation and cache policy. +/// +/// Safe to call more than once. The cookie is appended once, while the +/// private/no-store policy is reasserted so later adapter cache policy cannot +/// weaken it. +pub fn finalize_response(response: &mut Response) { + let Some(decision) = response + .extensions() + .get::() + .cloned() + else { + return; + }; + + if decision.cookie_action != ConsoleCookieAction::None + && response + .extensions() + .get::() + .is_none() + { + let value = match decision.cookie_action { + ConsoleCookieAction::None => None, + ConsoleCookieAction::SetSession => Some(HeaderValue::from_static(SET_CONSOLE_COOKIE)), + ConsoleCookieAction::ClearSession => { + Some(HeaderValue::from_static(CLEAR_CONSOLE_COOKIE)) + } + }; + if let Some(value) = value { + response.headers_mut().append(header::SET_COOKIE, value); + response.extensions_mut().insert(AdTraceCookieApplied); + } + } + + if decision.requires_private_no_store() { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + for name in crate::response_privacy::SURROGATE_CACHE_HEADERS { + response.headers_mut().remove(*name); + } + } +} + +fn trace_payload_request(request: &Request, eligible_navigation: bool) -> bool { + eligible_navigation + || request.uri().path() == "/auction" + || request.uri().path() == "/__ts/page-bids" +} + +fn is_eligible_console_navigation(request: &Request) -> bool { + request.method() == Method::GET + && is_navigation_request(request) + && !crate::publisher::is_prefetch_request(request) + && !crate::publisher::is_bot_user_agent(request) +} + +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 == QUERY_TS_CONSOLE { + 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() == COOKIE_TS_CONSOLE => { + state.occurrences += 1; + state.canonical |= value.trim() == "1"; + } + None if cookie == COOKIE_TS_CONSOLE => 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() != COOKIE_TS_CONSOLE, + None => *cookie != COOKIE_TS_CONSOLE, + }) + .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: "ad trace console query produced invalid URI".to_owned(), + })?, + ); + *request.uri_mut() = Uri::from_parts(parts).change_context(TrustedServerError::Proxy { + message: "ad trace console query produced invalid URI".to_owned(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use http::{Request, Response, header}; + + use crate::test_support::tests::create_test_settings; + + use super::*; + + fn settings(enabled: bool) -> Settings { + let mut settings = create_test_settings(); + settings.integrations.insert( + AD_TRACE_INTEGRATION_ID.to_owned(), + serde_json::json!({ "enabled": enabled }), + ); + settings + } + + fn request(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 rejects_unknown_gate_configuration() { + let mut settings = create_test_settings(); + settings.integrations.insert( + AD_TRACE_INTEGRATION_ID.to_owned(), + serde_json::json!({ "enabled": true, "enabledd": true }), + ); + + let error = settings + .integration_config::(AD_TRACE_INTEGRATION_ID) + .expect_err("should reject unknown gate field"); + assert!( + error.to_string().contains("could not be parsed"), + "should reject invalid configuration: {error}" + ); + } + + #[test] + fn query_enables_first_response_and_sanitizes_request() { + let mut req = request( + "https://publisher.example/page?x=%2F&ts_console=1&y=2", + Some("session=abc; __Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + + assert!(decision.enabled()); + assert_eq!(decision.cookie_action, ConsoleCookieAction::SetSession); + assert_eq!( + req.uri().to_string(), + "https://publisher.example/page?x=%2F&y=2" + ); + assert_eq!( + req.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookie"), + "session=abc" + ); + assert!(browser_trace_enabled(&req)); + let script = decision.bootstrap_script().expect("should bootstrap"); + assert!(script.contains("__tsjs_adTraceActive=true")); + assert!(script.contains("/page?x=%2F&y=2")); + + let mut separators = request( + "https://publisher.example/page?a=1&&ts_console=1&b=2&", + None, + ); + prepare_request(&settings(true), &mut separators).expect("should prepare"); + assert_eq!(separators.uri().query(), Some("a=1&&b=2&")); + } + + #[test] + fn exact_enable_and_disable_values_are_supported() { + for value in ["true", "1"] { + let mut req = request( + &format!("https://publisher.example/?ts_console={value}"), + None, + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(decision.enabled(), "{value} should enable"); + assert_eq!(decision.cookie_action, ConsoleCookieAction::SetSession); + } + for value in ["false", "0"] { + let mut req = request( + &format!("https://publisher.example/?ts_console={value}"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled(), "{value} should disable"); + assert_eq!(decision.cookie_action, ConsoleCookieAction::ClearSession); + } + } + + #[test] + fn invalid_or_duplicate_query_fails_closed_without_cookie_mutation() { + for query in [ + "ts_console=True", + "ts_console=", + "ts_console=1&ts_console=true", + ] { + let mut req = request( + &format!("https://publisher.example/?{query}&keep=1"), + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled(), "{query} should fail closed"); + assert_eq!(decision.cookie_action, ConsoleCookieAction::None); + assert_eq!(req.uri().query(), Some("keep=1")); + } + } + + #[test] + fn disabled_config_sanitizes_but_never_activates() { + let mut req = request( + "https://publisher.example/?ts_console=1&keep=1", + Some("__Host-ts-console=1; other=value; ts-tester=true"), + ); + let decision = prepare_request(&settings(false), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!(decision.cookie_action, ConsoleCookieAction::None); + assert_eq!(decision.clean_browser_path_and_query, None); + assert_eq!(req.uri().query(), Some("keep=1")); + assert_eq!( + req.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookies"), + "other=value; ts-tester=true" + ); + } + + #[test] + fn exact_session_cookie_gates_api_but_query_cannot_activate_it() { + let mut active = Request::builder() + .method(Method::POST) + .uri("https://publisher.example/auction") + .header(header::COOKIE, "__Host-ts-console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + assert!( + prepare_request(&settings(true), &mut active) + .expect("should prepare") + .enabled() + ); + + let mut query_only = Request::builder() + .method(Method::POST) + .uri("https://publisher.example/auction?ts_console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + let decision = prepare_request(&settings(true), &mut query_only).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!(decision.cookie_action, ConsoleCookieAction::None); + assert_eq!(query_only.uri().query(), None); + } + + #[test] + fn active_session_does_not_make_static_bundle_response_private() { + let mut req = Request::builder() + .method(Method::GET) + .uri("https://publisher.example/static/tsjs=tsjs-unified.min.js") + .header("sec-fetch-dest", "script") + .header(header::COOKIE, "__Host-ts-console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(decision.enabled()); + assert!(!decision.requires_private_no_store()); + assert_eq!(decision.bootstrap_script(), None); + } + + #[test] + fn invalid_api_query_fails_closed_even_with_session_cookie() { + let mut req = Request::builder() + .method(Method::POST) + .uri("https://publisher.example/auction?ts_console=invalid") + .header(header::COOKIE, "__Host-ts-console=1") + .body(EdgeBody::empty()) + .expect("should build request"); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!(req.uri().query(), None); + assert!(!req.headers().contains_key(header::COOKIE)); + } + + #[test] + fn duplicate_console_cookie_fails_closed_and_all_copies_are_removed() { + let mut req = request( + "https://publisher.example/", + Some("__Host-ts-console=1; a=b; __Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!( + req.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookie"), + "a=b" + ); + + let mut bare = request( + "https://publisher.example/", + Some("__Host-ts-console=1; __Host-ts-console; a=b"), + ); + let decision = prepare_request(&settings(true), &mut bare).expect("should prepare"); + assert!(!decision.enabled()); + assert_eq!( + bare.headers() + .get(header::COOKIE) + .expect("should retain unrelated cookie"), + "a=b" + ); + } + + #[test] + fn ts_tester_cookie_no_longer_activates_console() { + let mut req = request("https://publisher.example/", Some("ts-tester=true")); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + assert!(!decision.enabled()); + } + + #[test] + fn response_finalization_appends_cookie_once_and_reasserts_no_store() { + let mut req = request("https://publisher.example/?ts_console=1", None); + let decision = prepare_request(&settings(true), &mut req).expect("should prepare"); + let mut response = Response::builder() + .header(header::SET_COOKIE, "existing=value") + .header(header::CACHE_CONTROL, "public, max-age=60") + .header("surrogate-control", "max-age=60") + .header("cloudflare-cdn-cache-control", "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + attach_response_decision(&decision, &mut response); + + finalize_response(&mut response); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=60"), + ); + finalize_response(&mut response); + + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert!(!response.headers().contains_key("surrogate-control")); + assert!( + !response + .headers() + .contains_key("cloudflare-cdn-cache-control") + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 20e986156..5ce9f60a9 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1262,6 +1262,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build downstream request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &downstream, timeout_ms: 321, @@ -1394,6 +1395,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build downstream request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &downstream, timeout_ms: 321, @@ -1477,6 +1479,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build downstream request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &downstream, timeout_ms: 321, diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 9abf556c8..51af415c8 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -58,6 +58,48 @@ pubads.__tsInitialLoadHooked = true; }); + function captureRequest(slot, trigger) { + function firstTarget(key) { + if (!slot || typeof slot.getTargeting !== "function") return undefined; + var values = slot.getTargeting(key); + return values && values.length ? String(values[0]) : undefined; + } + var divId = + slot && typeof slot.getSlotElementId === "function" + ? slot.getSlotElementId() + : ""; + var slotId = (ts.divToSlotId || {})[divId]; + var liveBid = slotId && ts.bids ? ts.bids[slotId] : undefined; + var bidSnapshot = liveBid + ? Object.freeze( + Object.assign({}, liveBid, { + trace: liveBid.trace ? Object.freeze(Object.assign({}, liveBid.trace)) : undefined, + }), + ) + : undefined; + // Freeze request-boundary attribution before display()/refresh(). If the + // optional module loads later, draining this queue never rereads mutable GPT + // targeting or the current route's bid object. + var snapshot = Object.freeze({ + slotId: slotId, + bidder: firstTarget("hb_bidder"), + adId: firstTarget("hb_adid"), + traceToken: firstTarget("ts_trace"), + bid: bidSnapshot, + }); + if (typeof ts.captureAdTraceRequest === "function") { + ts.captureAdTraceRequest(slot, trigger, snapshot); + return; + } + // The unified bundle may load after this bootstrap. Queue private request + // ownership unconditionally so trace-off traffic receives the same stale + // render and billing protection; diagnostic fields remain independently gated. + ts.pendingAdTraceRequests = ts.pendingAdTraceRequests || []; + if (ts.pendingAdTraceRequests.length < 64) { + ts.pendingAdTraceRequests.push({ slot: slot, trigger: trigger, snapshot: snapshot }); + } + } + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -127,6 +169,9 @@ ].forEach(function (k) { if (b[k]) s.setTargeting(k, b[k]); }); + if (b.trace && b.trace.bidTraceId) { + s.setTargeting("ts_trace", b.trace.bidTraceId); + } // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); // Map both the inner div and the GPT slot's element ID (the @@ -159,6 +204,12 @@ // impression. Runs after enableServices(); on SPA navigation services are // already enabled, so this runs unconditionally for new slots. slotsToDisplay.forEach(function (divId) { + var requestSlot = newSlots.find(function (slot) { + return slot.getSlotElementId() === divId; + }); + if (requestSlot && !ts.gptInitialLoadDisabled) { + captureRequest(requestSlot, "bootstrap_display"); + } googletag.display(divId); }); // Reused publisher-owned slots always need a refresh to pick up the @@ -177,6 +228,9 @@ // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. ts.adInitRefreshInProgress = true; try { + slotsNeedingRefresh.forEach(function (slot) { + captureRequest(slot, "bootstrap_refresh"); + }); googletag.pubads().refresh(slotsNeedingRefresh); } finally { ts.adInitRefreshInProgress = false; diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 7bfaf27df..8d67f5c94 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -11,6 +11,7 @@ use crate::error::TrustedServerError; use crate::platform::{DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, RuntimeServices}; use crate::settings::Settings; +pub mod ad_trace; pub mod adserver_mock; pub mod aps; pub mod datadome; @@ -292,6 +293,10 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { id: "aps", build: aps::register, }, + IntegrationBuilder { + id: "ad_trace", + build: ad_trace::register, + }, IntegrationBuilder { id: "prebid", build: prebid::register, diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 1d17bd0a2..e683cf214 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2699,6 +2699,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &http_req, timeout_ms: 500, @@ -2742,6 +2743,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &http_req, timeout_ms: 500, @@ -2763,6 +2765,11 @@ mod tests { request_host, auction_request.publisher.domain, "request_host should be the publisher domain, not the edge Host header" ); + assert!( + !String::from_utf8_lossy(&bodies[0]) + .contains(&context.trace.auction_trace_id.to_string()), + "internal trace UUID should never be serialized upstream" + ); } fn create_test_auction_context<'a>( @@ -5406,13 +5413,14 @@ external_bundle_sri = "sha384-AAAA" prebid_platform_response(StatusCode::BAD_REQUEST, Some("application/json"), body); let provider_response = futures::executor::block_on(provider.parse_response(response, 42)) .expect("should classify upstream HTTP error"); - let result = OrchestrationResult { - provider_responses: vec![provider_response], - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: 42, - metadata: HashMap::new(), - }; + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new( + crate::auction::types::AuctionSource::AuctionApi, + ), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.provider_responses = vec![provider_response]; + result.total_time_ms = 42; let response = convert_to_openrtb_response( &result, &make_settings(), @@ -5529,6 +5537,7 @@ external_bundle_sri = "sha384-AAAA" .expect("should build request"); let services = noop_services(); let context = AuctionContext { + trace: crate::auction::test_support::test_trace(), settings: &settings, request: &http_req, timeout_ms: 1000, diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 9b7533505..dce748d46 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -180,16 +180,46 @@ impl ToExt for BidExt<'_> {} #[derive(Debug, Serialize)] pub struct BidTrustedServerExt<'a> { - pub renderer: &'a BidRenderer, + #[serde(skip_serializing_if = "Option::is_none")] + pub renderer: Option<&'a BidRenderer>, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace: Option, } #[derive(Debug, Serialize)] pub struct ResponseExt { pub orchestrator: OrchestratorExt, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_server: Option, } impl ToExt for ResponseExt {} +/// Namespaced Trusted Server response extensions. +#[derive(Debug, Serialize)] +pub struct TrustedServerResponseExt { + pub trace: AuctionTraceWire, +} + +/// Privacy-safe root trace extension. +#[derive(Debug, Serialize)] +pub struct AuctionTraceWire { + pub version: u8, + pub auction_trace_id: String, + pub source: &'static str, + pub outcome: &'static str, +} + +/// Privacy-safe final-winning-bid trace extension. +#[derive(Debug, Serialize)] +pub struct BidTraceWire { + pub version: u8, + pub bid_trace_id: String, + pub slot_id: String, + pub provider: String, + pub bidder: String, +} + #[cfg(test)] mod tests { use super::*; @@ -223,6 +253,7 @@ mod tests { time_ms: 12, provider_details: vec![], }, + trusted_server: None, } .to_ext(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 699dcbc97..f36487a5d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -37,7 +37,7 @@ use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; use crate::auction::orchestrator::{ - AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, + AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, OrchestrationResult, }; use crate::auction::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, @@ -348,6 +348,7 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + head_bootstrap_script: Option<&'a str>, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, } @@ -372,8 +373,14 @@ impl PublisherBodyProcessor { ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - Arc::clone(¶ms.ad_bids_state), + HtmlAdState { + head_bootstrap_script: params + .head_bootstrap_script + .as_deref() + .map(str::to_string), + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: Arc::clone(¶ms.ad_bids_state), + }, )?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( @@ -441,8 +448,11 @@ fn process_response_streaming( params.request_scheme, params.settings, params.integration_registry, - params.ad_slots_script.map(str::to_string), - params.ad_bids_state.clone(), + HtmlAdState { + head_bootstrap_script: params.head_bootstrap_script.map(str::to_string), + ad_slots_script: params.ad_slots_script.map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + }, )?; StreamingPipeline::new(config, processor).process(body_as_reader(body)?, output)?; } else if is_rsc_flight { @@ -767,12 +777,15 @@ async fn hold_collect_close_tail( collect_stream_auction( dispatched, state.telemetry.take(), - collect_refs.price_granularity, - collect_refs.ad_bids_state, - collect_refs.orchestrator, - collect_refs.services, - collect_refs.settings, - collect_refs.request_origin, + StreamAuctionFinalizeContext { + price_granularity: collect_refs.price_granularity, + ad_bids_state: collect_refs.ad_bids_state, + orchestrator: collect_refs.orchestrator, + services: collect_refs.services, + settings: collect_refs.settings, + request_origin: collect_refs.request_origin, + trace_enabled: collect_refs.trace_enabled, + }, ) .await; // Collection reached a terminal result; disarm only now so a drop while the @@ -903,14 +916,19 @@ async fn hold_finish_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. +struct HtmlAdState { + head_bootstrap_script: Option, + ad_slots_script: Option, + ad_bids_state: Arc>>, +} + fn create_html_stream_processor( origin_host: &str, request_host: &str, request_scheme: &str, settings: &Settings, integration_registry: &IntegrationRegistry, - ad_slots_script: Option, - ad_bids_state: Arc>>, + ad_state: HtmlAdState, ) -> Result, Report> { use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; @@ -921,7 +939,11 @@ fn create_html_stream_processor( request_host, request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state( + ad_state.head_bootstrap_script, + ad_state.ad_slots_script, + ad_state.ad_bids_state, + ); Ok(create_html_processor(config)) } @@ -1030,6 +1052,7 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) head_bootstrap_script: Option, pub(crate) ad_slots_script: Option, pub(crate) ad_bids_state: Arc>>, /// Observation context for the in-flight auction. @@ -1042,6 +1065,8 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, + /// Whether the config and exact tester cookie permit browser trace output. + pub(crate) ad_trace_enabled: bool, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -1284,6 +1309,7 @@ pub async fn publisher_response_into_streaming_response( services: &services, settings: &settings, request_origin: &request_origin, + trace_enabled: params.ad_trace_enabled, }; while let Some(step) = hold_step_next_chunk( @@ -1470,6 +1496,7 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + head_bootstrap_script: params.head_bootstrap_script.as_deref(), ad_slots_script: params.ad_slots_script.as_deref(), ad_bids_state: ¶ms.ad_bids_state, }; @@ -1562,8 +1589,11 @@ pub async fn stream_publisher_body_async( ¶ms.request_scheme, settings, integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - params.ad_bids_state.clone(), + HtmlAdState { + head_bootstrap_script: params.head_bootstrap_script.as_deref().map(str::to_string), + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + }, ) { Ok(processor) => processor, Err(err) => { @@ -1593,6 +1623,7 @@ pub async fn stream_publisher_body_async( services, settings, request_origin: request_origin(¶ms.request_scheme, ¶ms.request_host), + trace_enabled: params.ad_trace_enabled, }, ) .await @@ -1621,6 +1652,7 @@ fn mediator_placeholder_request() -> Request { /// this argument is plumbing for the (presently unused) case where the /// orchestrator needs the caller's request shape. fn make_collect_context<'a>( + trace: &'a crate::auction::types::AuctionTraceContext, settings: &'a Settings, services: &'a RuntimeServices, placeholder: &'a Request, @@ -1632,6 +1664,7 @@ fn make_collect_context<'a>( callers must not forward a real client request through the collect path" ); AuctionContext { + trace, settings, request: placeholder, timeout_ms: 0, @@ -1709,26 +1742,36 @@ fn request_origin(scheme: &str, host: &str) -> String { /// Write winning bids from an auction result into the shared `ad_bids_state` lock. pub(crate) fn write_bids_to_state( - winning_bids: &std::collections::HashMap, + result: &crate::auction::orchestrator::OrchestrationResult, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, settings: &Settings, request_origin: &str, include_debug_bid: bool, + trace_enabled: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", - winning_bids.len(), - winning_bids.keys().cloned().collect::>().join(", ") + result.winning_bids.len(), + result + .winning_bids + .keys() + .cloned() + .collect::>() + .join(", ") ); - let bid_map = build_bid_map( - winning_bids, + let bid_map = build_bid_map_with_trace( + result, price_granularity, settings, request_origin, include_debug_bid, + trace_enabled, + ); + let bids_script = build_bids_script_with_trace( + &bid_map, + trace_enabled.then(|| auction_trace_json(&result.trace.summary)), ); - let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1939,6 +1982,7 @@ struct AuctionCollectCtx<'a> { settings: &'a Settings, /// Trusted request origin (`scheme://host`) for absolute inline creative URLs. request_origin: String, + trace_enabled: bool, } struct AuctionHoldCollectRefs<'a> { @@ -1949,6 +1993,7 @@ struct AuctionHoldCollectRefs<'a> { settings: &'a Settings, /// Trusted request origin (`scheme://host`) for absolute inline creative URLs. request_origin: &'a str, + trace_enabled: bool, } /// Run the close-body hold loop for HTML bodies, collecting the auction before @@ -2038,6 +2083,7 @@ async fn body_close_hold_loop_stream( services, settings, request_origin, + trace_enabled, } = ctx; let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); @@ -2050,6 +2096,7 @@ async fn body_close_hold_loop_stream( services, settings, request_origin: &request_origin, + trace_enabled, }; while let Some(step) = hold_step_next_chunk( @@ -2168,6 +2215,7 @@ async fn body_close_hold_loop( services, settings, request_origin, + trace_enabled, } = ctx; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; let mut hold = Some(BodyCloseHoldBuffer::new()); @@ -2183,12 +2231,15 @@ async fn body_close_hold_loop( collect_stream_auction( dispatched, telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - &request_origin, + StreamAuctionFinalizeContext { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + request_origin: &request_origin, + trace_enabled, + }, ) .await; @@ -2247,12 +2298,15 @@ async fn body_close_hold_loop( collect_stream_auction( dispatched, telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - &request_origin, + StreamAuctionFinalizeContext { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + request_origin: &request_origin, + trace_enabled, + }, ) .await; @@ -2340,11 +2394,12 @@ async fn collect_non_html_auction( settings: &Settings, ) { let placeholder = mediator_placeholder_request(); + let trace = dispatched.trace().clone(); let result = orchestrator .collect_dispatched_auction( dispatched, services, - &make_collect_context(settings, services, &placeholder), + &make_collect_context(&trace, settings, services, &placeholder), ) .await; if let (Some(observation), Some(auction_request)) = @@ -2362,32 +2417,44 @@ async fn collect_non_html_auction( .await; } write_bids_to_state( - &result.winning_bids, + &result, params.price_granularity, ¶ms.ad_bids_state, settings, &request_origin(¶ms.request_scheme, ¶ms.request_host), settings.debug.inject_adm_for_testing, + params.ad_trace_enabled, ); } -// Private orchestration helper called only from `body_close_hold_loop`, whose -// arguments mirror the fields of `AuctionCollectCtx` it destructures; a separate -// parameter struct would just duplicate that context. -#[allow(clippy::too_many_arguments)] +struct StreamAuctionFinalizeContext<'a> { + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, + request_origin: &'a str, + trace_enabled: bool, +} + async fn collect_stream_auction( dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry, - price_granularity: PriceGranularity, - ad_bids_state: &Arc>>, - orchestrator: &AuctionOrchestrator, - services: &RuntimeServices, - settings: &Settings, - request_origin: &str, + context: StreamAuctionFinalizeContext<'_>, ) { + let StreamAuctionFinalizeContext { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + request_origin, + trace_enabled, + } = context; log::info!("body_close_hold_loop: collecting dispatched auction before held body tail"); let placeholder = mediator_placeholder_request(); - let collect_ctx = make_collect_context(settings, services, &placeholder); + let trace = dispatched.trace().clone(); + let collect_ctx = make_collect_context(&trace, settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; @@ -2410,12 +2477,13 @@ async fn collect_stream_auction( result.winning_bids.len() ); write_bids_to_state( - &result.winning_bids, + &result, price_granularity, ad_bids_state, settings, request_origin, settings.debug.inject_adm_for_testing, + trace_enabled, ); if settings.debug.auction_html_comment { @@ -2520,6 +2588,9 @@ pub async fn handle_publisher_request( ); let consent_context = ec_context.consent().clone(); + let ad_trace_decision = crate::integrations::ad_trace::request_decision(&req); + let ad_trace_enabled = ad_trace_decision.enabled(); + let ad_trace_bootstrap = ad_trace_decision.bootstrap_script(); let ec_id = ec_context.ec_value().filter(|_| ec_allowed); let cookie_jar = handle_request_cookies(&req)?; let geo = ec_context.geo_info().cloned(); @@ -2638,13 +2709,15 @@ pub async fn handle_publisher_request( let mut dispatched_auction = if matched_slots.is_empty() { None } else { + let trace = + crate::auction::types::AuctionTraceContext::new(AuctionSource::InitialNavigation); // Telemetry attribution must use the same publisher identity as the // outbound bid request. On the navigation path `request_host` is the // trusted-server edge host, so using it here would attribute navigation // rows to the edge/staging domain while `/auction` rows (built from // `AuctionRequest::publisher.domain`) use the configured domain. let observation = AuctionObservationContext::from_parts( - AuctionSource::InitialNavigation, + &trace, &settings.publisher.domain, &request_path, matched_slots.len(), @@ -2680,6 +2753,7 @@ pub async fn handle_publisher_request( }, ); let auction_context = AuctionContext { + trace: &trace, settings, request: &req, timeout_ms: auction_timeout_ms, @@ -2701,6 +2775,21 @@ pub async fn handle_publisher_request( provider_responses, elapsed_ms, } => { + if ad_trace_enabled { + let terminal = OrchestrationResult::empty( + trace.clone(), + crate::auction::types::AuctionPublicOutcome::Failed, + ); + write_bids_to_state( + &terminal, + price_granularity, + &ad_bids_state, + settings, + &request_origin(request_scheme, request_host), + settings.debug.inject_adm_for_testing, + true, + ); + } emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -2716,6 +2805,21 @@ pub async fn handle_publisher_request( None } DispatchAuctionOutcome::NotStarted => { + if ad_trace_enabled { + let terminal = OrchestrationResult::empty( + trace.clone(), + crate::auction::types::AuctionPublicOutcome::Failed, + ); + write_bids_to_state( + &terminal, + price_granularity, + &ad_bids_state, + settings, + &request_origin(request_scheme, request_host), + settings.debug.inject_adm_for_testing, + true, + ); + } let elapsed_ms = observation.elapsed_ms(); emit_auction_events_best_effort_lazy(services, || { build_auction_events( @@ -2989,12 +3093,14 @@ pub async fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + head_bootstrap_script: ad_trace_bootstrap.clone(), ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), auction_observation, auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + ad_trace_enabled, }), }) } @@ -3307,14 +3413,80 @@ pub(crate) fn build_bid_map( .collect() } +fn auction_trace_json(summary: &crate::auction::types::AuctionTraceSummary) -> serde_json::Value { + serde_json::json!({ + "version": 1, + "auctionTraceId": summary.auction.auction_trace_id.to_string(), + "source": summary.auction.source.as_str(), + "outcome": summary.outcome.as_str(), + }) +} + +fn apply_bid_traces( + bid_map: &mut serde_json::Map, + result_trace: &crate::auction::types::AuctionResultTrace, +) { + for (slot_id, trace) in &result_trace.winning_bids { + if let Some(serde_json::Value::Object(bid)) = bid_map.get_mut(slot_id) { + bid.insert( + "trace".to_owned(), + serde_json::json!({ + "version": 1, + "auctionTraceId": result_trace.summary.auction.auction_trace_id.to_string(), + "bidTraceId": trace.bid_trace_id.to_string(), + "source": result_trace.summary.auction.source.as_str(), + "slotId": slot_id, + "provider": trace.provider, + "bidder": trace.bidder, + }), + ); + } + } +} + +fn build_bid_map_with_trace( + result: &crate::auction::orchestrator::OrchestrationResult, + granularity: crate::price_bucket::PriceGranularity, + settings: &Settings, + request_origin: &str, + include_debug_bid: bool, + trace_enabled: bool, +) -> serde_json::Map { + let mut bid_map = build_bid_map( + &result.winning_bids, + granularity, + settings, + request_origin, + include_debug_bid, + ); + if !trace_enabled { + return bid_map; + } + apply_bid_traces(&mut bid_map, &result.trace); + bid_map +} + /// Build the `tsjs.bids` `` sequences inside the string. pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { + build_bids_script_with_trace(bid_map, None) +} + +fn build_bids_script_with_trace( + bid_map: &serde_json::Map, + auction_trace: Option, +) -> String { let json = serde_json::to_string(bid_map) .expect("serde_json::to_string of Map should be infallible"); let escaped = html_escape_for_script(&json); + let trace_assignment = auction_trace.map_or_else(String::new, |trace| { + let trace_json = serde_json::to_string(&trace) + .expect("serde_json::to_string of trace should be infallible"); + let escaped_trace = html_escape_for_script(&trace_json); + format!("window.tsjs.auctionTrace=JSON.parse(\"{escaped_trace}\");") + }); // adInit() defines GPT slots on the publisher's `-container` wrappers, which // mutates those ad-slot subtrees. Calling it synchronously here (this script // runs at body-parse time) lands those mutations inside React's hydration @@ -3326,11 +3498,10 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map(window.tsjs=window.tsjs||{{}}).bids=JSON.parse(\"{}\");\ + "", - escaped +}})();" ) } @@ -3556,6 +3726,7 @@ pub async fn handle_page_bids( ); return Ok(page_bids_preflight_denied()); } + let trace_enabled = crate::integrations::ad_trace::browser_trace_enabled(&req); let requested_page = req .uri() @@ -3617,13 +3788,21 @@ pub async fn handle_page_bids( // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; - let winning_bids = if matched_slots.is_empty() { - std::collections::HashMap::new() + let trace = crate::auction::types::AuctionTraceContext::new(AuctionSource::SpaNavigation); + let mut result_trace = crate::auction::types::AuctionResultTrace { + summary: crate::auction::types::AuctionTraceSummary { + auction: trace.clone(), + outcome: crate::auction::types::AuctionPublicOutcome::Skipped, + }, + winning_bids: std::collections::HashMap::new(), + }; + let completed_result: Option = if matched_slots.is_empty() { + None } else { // Same publisher identity as the outbound bid request — see the // matching note on the initial-navigation observation above. let observation = AuctionObservationContext::from_parts( - AuctionSource::SpaNavigation, + &trace, &settings.publisher.domain, &path_param, matched_slots.len(), @@ -3661,6 +3840,7 @@ pub async fn handle_page_bids( .auction_timeout_ms .unwrap_or(settings.auction.timeout_ms); let auction_context = AuctionContext { + trace: &trace, settings, request: &req, timeout_ms, @@ -3673,7 +3853,7 @@ pub async fn handle_page_bids( .await { Ok(result) => { - let winning_bids = result.winning_bids.clone(); + result_trace = result.trace.clone(); emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, @@ -3684,10 +3864,12 @@ pub async fn handle_page_bids( ) }) .await; - winning_bids + Some(result) } Err(e) => { log::warn!("page-bids auction failed: {e:?}"); + result_trace.summary.outcome = + crate::auction::types::AuctionPublicOutcome::Failed; let elapsed_ms = observation.elapsed_ms(); emit_auction_events_best_effort_lazy(services, || { build_auction_events( @@ -3701,7 +3883,7 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + None } } } else { @@ -3727,17 +3909,25 @@ pub async fn handle_page_bids( ) }) .await; - std::collections::HashMap::new() + None } }; - let bid_map = build_bid_map( + let winning_bids = completed_result + .as_ref() + .map(|result| &result.winning_bids) + .cloned() + .unwrap_or_default(); + let mut bid_map = build_bid_map( &winning_bids, co_config.price_granularity, settings, &page_bids_request_origin, settings.debug.inject_adm_for_testing, ); + if trace_enabled { + apply_bid_traces(&mut bid_map, &result_trace); + } // Gate slots on the ad-stack kill switch / consent: when disabled, return no // slots so the SPA hook does not call `adInit()` / create GPT slots. @@ -3750,10 +3940,13 @@ pub async fn handle_page_bids( Vec::new() }; - let body = serde_json::json!({ + let mut body = serde_json::json!({ "slots": slots_json, "bids": bid_map, }); + if trace_enabled && !matched_slots.is_empty() { + body["auctionTrace"] = auction_trace_json(&result_trace.summary); + } let json_str = serde_json::to_string(&body).change_context(TrustedServerError::Proxy { message: "Failed to serialize page-bids response".to_string(), @@ -3825,16 +4018,15 @@ mod tests { fn dump_comment_for_creative(creative: &str) -> String { let mut bid = make_test_bid_with_creative(creative); bid.slot_id = "ad-header-0".to_string(); - let result = OrchestrationResult { - provider_responses: vec![ - AuctionResponse::no_bid("prebid", 665), - AuctionResponse::success("aps", vec![bid], 42), - ], - mediator_response: None, - winning_bids: std::collections::HashMap::new(), - total_time_ms: 665, - metadata: std::collections::HashMap::new(), - }; + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new(AuctionSource::InitialNavigation), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.provider_responses = vec![ + AuctionResponse::no_bid("prebid", 665), + AuctionResponse::success("aps", vec![bid], 42), + ]; + result.total_time_ms = 665; let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); prepend_auction_debug_comment("stream", &result, &state); let comment = state @@ -3889,13 +4081,12 @@ mod tests { ) // An allowlisted key must still survive. .with_metadata("error_type", serde_json::json!("http_status")); - let result = OrchestrationResult { - provider_responses: vec![response], - mediator_response: None, - winning_bids: std::collections::HashMap::new(), - total_time_ms: 12, - metadata: std::collections::HashMap::new(), - }; + let mut result = OrchestrationResult::empty( + crate::auction::types::AuctionTraceContext::new(AuctionSource::InitialNavigation), + crate::auction::types::AuctionPublicOutcome::NoBid, + ); + result.provider_responses = vec![response]; + result.total_time_ms = 12; let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); prepend_auction_debug_comment("stream", &result, &state); let comment = state @@ -4070,12 +4261,14 @@ mod tests { request_host: settings.publisher.domain.clone(), request_scheme: "https".to_owned(), content_type: "application/json".to_owned(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: Default::default(), + ad_trace_enabled: false, } } @@ -5100,6 +5293,7 @@ mod tests { services: &services, settings: &settings, request_origin: String::new(), + trace_enabled: false, }; let mut output = Vec::new(); @@ -5148,6 +5342,7 @@ mod tests { services: &services, settings: &settings, request_origin: "", + trace_enabled: false, }; // Passthrough processor: the ordering contract is about collection, not // HTML rewriting, so keep the emitted bytes verbatim. @@ -5819,12 +6014,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); @@ -5866,12 +6063,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); @@ -5902,12 +6101,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), @@ -6016,12 +6217,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), @@ -6068,12 +6271,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let compressed = gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -6123,12 +6328,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let compressed = deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -6178,12 +6385,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -6233,12 +6442,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -6276,12 +6487,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, } } @@ -6463,6 +6676,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: Some( r#""# .to_string(), @@ -6475,6 +6689,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"hello"), @@ -6525,6 +6740,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, @@ -6534,6 +6750,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( b"body{background:url('https://origin.example.com/asset.png')}", @@ -6583,12 +6800,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let publisher_response = PublisherResponse::Stream { response, @@ -6806,10 +7025,11 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: Some(AuctionObservationContext::from_parts( - AuctionSource::SpaNavigation, + &crate::auction::types::AuctionTraceContext::new(AuctionSource::SpaNavigation), "proxy.example.com", "/article", 1, @@ -6821,6 +7041,7 @@ mod tests { 10, )), price_granularity: PriceGranularity::default(), + ad_trace_enabled: false, } }; let make_stream_response = || PublisherResponse::Stream { @@ -6987,6 +7208,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: Some( r#""# .to_string(), @@ -6999,6 +7221,7 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let publisher_response = PublisherResponse::Stream { response, @@ -7056,6 +7279,7 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "Text/HTML; Charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: Some( r#""# .to_string(), @@ -7065,6 +7289,7 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); @@ -7108,12 +7333,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -7215,12 +7442,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -7271,12 +7500,14 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + head_bootstrap_script: None, ad_slots_script: None, ad_bids_state: Arc::new(Mutex::new(None)), auction_observation: None, auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), + ad_trace_enabled: false, }; let mut output = Vec::new(); @@ -7308,7 +7539,7 @@ mod tests { mod creative_opportunities_tests { use super::super::{ MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, - build_bids_script, html_escape_for_script, + build_bids_script, build_bids_script_with_trace, html_escape_for_script, }; use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; use crate::consent::ConsentContext; @@ -8164,6 +8395,30 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in bids script"); } + #[test] + fn traced_bids_script_assigns_summary_and_bids_before_ad_init() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let trace = serde_json::json!({ + "version": 1, + "auctionTraceId": "550e8400-e29b-41d4-a716-446655440000", + "source": "initial_navigation", + "outcome": "completed", + }); + + let script = build_bids_script_with_trace(&map, Some(trace)); + + let trace_pos = script + .find(".auctionTrace=JSON.parse") + .expect("should assign trace"); + let bids_pos = script.find(".bids=JSON.parse").expect("should assign bids"); + let init_pos = script.find("adInit").expect("should invoke adInit"); + assert!( + trace_pos < bids_pos && bids_pos < init_pos, + "should atomically assign trace and bids before adInit" + ); + } + #[test] fn bids_script_calls_ad_init_without_retry_timer() { let mut map = serde_json::Map::new(); @@ -8515,8 +8770,10 @@ mod tests { orchestrator: &AuctionOrchestrator, slots: &[CreativeOpportunitySlot], ec_context: &EcContext, - req: Request, + mut req: Request, ) -> Response { + crate::integrations::ad_trace::prepare_request(settings, &mut req) + .expect("should prepare ad trace request"); let services = noop_services(); handle_page_bids( settings, @@ -8700,11 +8957,17 @@ mod tests { #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { - // Slots exist but request path does not match — no auction, no injection. - let settings = settings_with_co(); + // Slots exist but request path does not match — no auction, no injection, + // and no unjoinable trace identity even when the tester gate is open. + let mut settings = settings_with_co(); + settings + .integrations + .insert_config("ad_trace", &serde_json::json!({ "enabled": true })) + .expect("should configure ad trace"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let slots = article_slot(); // slot matches /20** only - let req = make_page_bids_request("/about"); // does not match + let mut req = make_page_bids_request("/about"); // does not match + set_test_header(&mut req, "cookie", "__Host-ts-console=1"); let body = run_page_bids(&settings, &orchestrator, &slots, req).await; @@ -8724,6 +8987,45 @@ mod tests { 0, "non-matching URL should produce zero bids" ); + assert!( + body.get("auctionTrace").is_none(), + "non-matching URL should not expose an identity without telemetry" + ); + } + + #[tokio::test] + async fn page_bids_trace_requires_config_and_console_session() { + let mut settings = settings_with_co_auction_disabled(); + settings + .integrations + .insert_config("ad_trace", &serde_json::json!({ "enabled": true })) + .expect("should configure ad trace"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + + let without_cookie = make_page_bids_request("/2024/01/my-article/"); + let without_cookie_body = + run_page_bids_consent_allowed(&settings, &orchestrator, &slots, without_cookie) + .await; + assert!( + without_cookie_body.get("auctionTrace").is_none(), + "config alone should not expose trace" + ); + + let mut gated = make_page_bids_request("/2024/01/my-article/"); + set_test_header(&mut gated, "cookie", "__Host-ts-console=1"); + let gated_body = + run_page_bids_consent_allowed(&settings, &orchestrator, &slots, gated).await; + assert_eq!( + gated_body["auctionTrace"]["source"], + serde_json::json!("spa_navigation"), + "both gates should expose generic SPA trace" + ); + assert_eq!( + gated_body["auctionTrace"]["outcome"], + serde_json::json!("skipped"), + "disabled auction should not be fabricated as completed no-bid" + ); } #[test] diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 27a94b62d..262d3fc95 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -17,7 +17,12 @@ 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", + "cdn-cache-control", + "cloudflare-cdn-cache-control", +]; /// Forces cookie-bearing responses to stay private to shared caches. /// @@ -82,8 +87,9 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: for (key, value) in &settings.response_headers { 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")) + || SURROGATE_CACHE_HEADERS + .iter() + .any(|name| key.eq_ignore_ascii_case(name))) { continue; } diff --git a/crates/trusted-server-integration-tests/browser/global-setup.ts b/crates/trusted-server-integration-tests/browser/global-setup.ts index f54d92dbe..14b3b0539 100644 --- a/crates/trusted-server-integration-tests/browser/global-setup.ts +++ b/crates/trusted-server-integration-tests/browser/global-setup.ts @@ -16,12 +16,11 @@ const WASM_PATH = "../../../target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm", ); -const VICEROY_CONFIG = - process.env.VICEROY_CONFIG_PATH || - resolve( - __dirname, - "../../../target/integration-test-artifacts/configs/viceroy.toml", - ); +function viceroyConfigPath(framework: string): string { + if (process.env.VICEROY_CONFIG_PATH) return process.env.VICEROY_CONFIG_PATH; + const filename = framework === "ad-trace" ? "viceroy-ad-trace.toml" : "viceroy.toml"; + return resolve(__dirname, `../../../target/integration-test-artifacts/configs/${filename}`); +} /** Persist current state so global-teardown can always clean up. */ function writeState(state: { @@ -47,7 +46,7 @@ async function globalSetup(): Promise { writeState({ containerId, framework }); console.log(`[global-setup] Starting Viceroy (WASM: ${WASM_PATH})...`); - const viceroy = await startViceroy(WASM_PATH, VICEROY_CONFIG); + const viceroy = await startViceroy(WASM_PATH, viceroyConfigPath(framework)); viceroyPid = viceroy.process.pid; console.log(`[global-setup] Viceroy ready at ${viceroy.baseUrl}`); diff --git a/crates/trusted-server-integration-tests/browser/helpers/infra.ts b/crates/trusted-server-integration-tests/browser/helpers/infra.ts index 0402bb266..1b7682b6b 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/infra.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/infra.ts @@ -7,6 +7,7 @@ const ORIGIN_PORT = process.env.INTEGRATION_ORIGIN_PORT || "8888"; /** Framework-specific container configuration. */ const FRAMEWORK_CONFIG: Record = { + "ad-trace": { image: "test-ad-trace:latest", port: 80 }, nextjs: { image: "test-nextjs:latest", port: 3000 }, wordpress: { image: "test-wordpress:latest", port: 80 }, }; diff --git a/crates/trusted-server-integration-tests/browser/helpers/state.ts b/crates/trusted-server-integration-tests/browser/helpers/state.ts index b8f5d4b66..dd655d01c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/state.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/state.ts @@ -8,7 +8,7 @@ export interface TestState { framework: string; } -const KNOWN_FRAMEWORKS = ["nextjs", "wordpress"] as const; +const KNOWN_FRAMEWORKS = ["ad-trace", "nextjs", "wordpress"] as const; const STATE_FILE = resolve(__dirname, "../.browser-test-state.json"); let cachedState: TestState | undefined; diff --git a/crates/trusted-server-integration-tests/browser/package.json b/crates/trusted-server-integration-tests/browser/package.json index 13282f289..72b3fb997 100644 --- a/crates/trusted-server-integration-tests/browser/package.json +++ b/crates/trusted-server-integration-tests/browser/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "test": "npx playwright test", + "test:ad-trace": "TEST_FRAMEWORK=ad-trace npx playwright test tests/ad-trace/auction-trace.spec.ts", "test:nextjs": "TEST_FRAMEWORK=nextjs npx playwright test", "test:wordpress": "TEST_FRAMEWORK=wordpress npx playwright test" }, diff --git a/crates/trusted-server-integration-tests/browser/playwright.config.ts b/crates/trusted-server-integration-tests/browser/playwright.config.ts index 8a1ef3b5b..812c889ec 100644 --- a/crates/trusted-server-integration-tests/browser/playwright.config.ts +++ b/crates/trusted-server-integration-tests/browser/playwright.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from "@playwright/test"; +const framework = process.env.TEST_FRAMEWORK || "nextjs"; + export default defineConfig({ testDir: "./tests", + testMatch: + framework === "ad-trace" + ? ["ad-trace/**/*.spec.ts"] + : ["nextjs/**/*.spec.ts", "shared/**/*.spec.ts", "wordpress/**/*.spec.ts"], globalSetup: "./global-setup.ts", globalTeardown: "./global-teardown.ts", timeout: 30_000, @@ -20,5 +26,5 @@ export default defineConfig({ }, ], reporter: [["list"], ["html", { open: "never" }]], - outputDir: "./test-results", + outputDir: `./test-results-${framework}`, }); diff --git a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts new file mode 100644 index 000000000..9c2935d07 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts @@ -0,0 +1,439 @@ +import { expect, test, type Page } from "@playwright/test"; +import { runtimeUrl } from "../../helpers/state.js"; + +const ORIGIN_PORT = process.env.INTEGRATION_ORIGIN_PORT || "8888"; + +async function serveBuiltPrebid(page: Page): Promise { + const response = await fetch( + `http://127.0.0.1:${ORIGIN_PORT}/prebid-bundle.js`, + ); + if (!response.ok) + throw new Error(`fixture Prebid bundle returned ${response.status}`); + const body = await response.text(); + await page.route("**/integrations/prebid/bundle.js*", (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript", + body, + }), + ); +} + +async function openTesterPage(page: Page): Promise { + await serveBuiltPrebid(page); + await page.goto(runtimeUrl("/?ts_console=1"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/")); + await expect + .poll(() => + page.evaluate(() => + ( + window as Window & { + tsjs?: { adTrace?: { export(): unknown } }; + } + ).tsjs?.adTrace?.export(), + ), + ) + .toBeTruthy(); + await expect + .poll(() => + page.evaluate(() => { + const result = ( + window as Window & { + tsjs?: { + adTrace?: { + export(): { + slots: Array<{ + slotId: string; + stages: { + creative: { outcome: string }; + }; + }>; + }; + }; + }; + } + ).tsjs?.adTrace?.export(); + return result?.slots.find( + (slot) => slot.slotId === "ad-trace-slot", + )?.stages.creative.outcome; + }), + ) + .toBe("load_acknowledged"); + await expect + .poll(() => + page.evaluate(() => + ( + window as Window & { + tsjs?: { + adTrace?: { + getEvents(): Array<{ kind: string }>; + }; + }; + } + ).tsjs?.adTrace + ?.getEvents() + .some((event) => event.kind === "gpt_slot_render_ended"), + ), + ) + .toBe(true); +} + +async function exported(page: Page) { + return page.evaluate(() => + ( + window as Window & { + tsjs: { + adTrace: { + export(): { slots: Array> }; + }; + }; + } + ).tsjs.adTrace.export(), + ); +} + +test.describe("tester-only auction trace contract", () => { + test("config without an activated console session exposes no browser trace surface", async ({ + page, + }) => { + await serveBuiltPrebid(page); + await page.goto(runtimeUrl("/"), { waitUntil: "domcontentloaded" }); + + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("undefined"); + }); + + test("console session supports true, persists privately, and can be disabled", async ({ + page, + }) => { + await serveBuiltPrebid(page); + const activation = await page.goto(runtimeUrl("/?ts_console=true"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/")); + expect(activation?.headers()["cache-control"]).toBe("private, no-store"); + await expect + .poll(() => + page.evaluate( + () => + typeof ( + window as Window & { tsjs?: { adTrace?: unknown } } + ).tsjs?.adTrace, + ), + ) + .toBe("object"); + expect( + (await page.context().cookies()).find( + (cookie) => cookie.name === "__Host-ts-console", + ), + ).toMatchObject({ + value: "1", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + + await page.reload({ waitUntil: "domcontentloaded" }); + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("object"); + + await page.goto(runtimeUrl("/?ts_console=0"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/")); + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("undefined"); + + await page.reload({ waitUntil: "domcontentloaded" }); + expect( + await page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ).toBe("undefined"); + }); + + test("initial TS winner reaches direct GPT and source-validated creative acknowledgement", async ({ + page, + }) => { + await openTesterPage(page); + + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { + stages?: Record< + string, + { outcome?: string; confidence?: string } + >; + } + | undefined; + return { + trustedServer: slot?.stages?.trustedServer?.outcome, + prebid: slot?.stages?.prebid?.outcome, + gam: slot?.stages?.gam?.outcome, + creative: slot?.stages?.creative?.outcome, + }; + }) + .toEqual({ + trustedServer: "won", + prebid: "not_run", + gam: "trusted_server_won", + creative: "load_acknowledged", + }); + + const session = await page.context().newCDPSession(page); + const tree = (await session.send("Accessibility.getFullAXTree")) as { + nodes: Array<{ name?: { value?: string } }>; + }; + const visibleText = tree.nodes + .map((node) => node.name?.value || "") + .join("\n"); + expect(visibleText).toContain("TS winner: won · definitive"); + expect(visibleText).toContain( + "Creative: load_acknowledged · definitive", + ); + }); + + test("direct auction API render reaches an exact iframe-load acknowledgement", async ({ + page, + }) => { + await openTesterPage(page); + await page.evaluate(() => { + const direct = document.createElement("div"); + direct.id = "direct-api-slot"; + document.body.appendChild(direct); + const ts = (window as Window & { + tsjs: { + addAdUnits(unit: unknown): void; + requestAds(): void; + }; + }).tsjs; + ts.addAdUnits({ + code: "direct-api-slot", + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: "example", params: {} }], + }); + ts.requestAds(); + }); + + await expect + .poll(() => + page.evaluate(() => { + const result = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + renders: Array<{ + slotId: string; + source: string; + outcome: string; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export(); + return result.renders.find( + (render) => render.slotId === "direct-api-slot", + ); + }), + ) + .toMatchObject({ + slotId: "direct-api-slot", + source: "direct_auction", + outcome: "confirmed", + }); + }); + + test("actual generated Prebid selects the traced TS bid before a probable GAM result", async ({ + page, + }) => { + await openTesterPage(page); + await expect + .poll(() => + page.evaluate(() => { + const win = window as Window & { + pbjs?: { requestBids?: unknown }; + googletag?: { + pubads(): { __tsRefreshWrapped?: boolean }; + }; + }; + return ( + typeof win.pbjs?.requestBids === "function" && + win.googletag?.pubads().__tsRefreshWrapped === true + ); + }), + ) + .toBe(true); + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + latestSlot(): unknown; + setSuppressCreative(value: boolean): void; + }; + googletag: { pubads(): { refresh(slots: unknown[]): void } }; + }; + win.adTraceFixture.setSuppressCreative(true); + win.googletag.pubads().refresh([win.adTraceFixture.latestSlot()]); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { + stages?: Record< + string, + { outcome?: string; confidence?: string } + >; + } + | undefined; + return { + prebid: slot?.stages?.prebid, + gam: slot?.stages?.gam, + }; + }) + .toMatchObject({ + prebid: { outcome: "won", confidence: "definitive" }, + gam: { + outcome: "trusted_server_candidate", + confidence: "probable", + }, + }); + }); + + test("client selection, backfill, direct-or-unattributed, and retained generations stay independent", async ({ + page, + }) => { + await openTesterPage(page); + + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { simulateClientSelection(): void }; + }; + win.adTraceFixture.simulateClientSelection(); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { stages?: Record } + | undefined; + return { + prebid: slot?.stages?.prebid?.outcome, + gam: slot?.stages?.gam?.outcome, + }; + }) + .toEqual({ prebid: "lost", gam: "client_prebid_candidate" }); + + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + latestSlot(): unknown; + setNextRender(flags: { isBackfill: boolean }): void; + requestCurrent(): void; + }; + tsjs: { + captureAdTraceRequest(slot: unknown, trigger: string): void; + }; + }; + const slot = win.adTraceFixture.latestSlot(); + win.adTraceFixture.setNextRender({ isBackfill: true }); + win.tsjs.captureAdTraceRequest(slot, "fixture_backfill"); + win.adTraceFixture.requestCurrent(); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { stages?: Record } + | undefined; + return slot?.stages?.gam?.outcome; + }) + .toBe("backfill"); + + await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + latestSlot(): { clearTargeting(): void }; + requestCurrent(): void; + }; + tsjs: { + captureAdTraceRequest(slot: unknown, trigger: string): void; + }; + }; + const slot = win.adTraceFixture.latestSlot(); + slot.clearTargeting(); + win.tsjs.captureAdTraceRequest(slot, "fixture_direct"); + win.adTraceFixture.requestCurrent(); + }); + await expect + .poll(async () => { + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as + | { stages?: Record } + | undefined; + return slot?.stages?.gam?.outcome; + }) + .toBe("direct_or_unattributed"); + + const generations = await page.evaluate(() => { + const win = window as Window & { + adTraceFixture: { + simulateRetainedGenerationAcknowledgement(): unknown; + }; + }; + return win.adTraceFixture.simulateRetainedGenerationAcknowledgement(); + }); + const result = await exported(page); + const slot = result.slots.find( + (item) => item.slotId === "ad-trace-slot", + ) as { + latestGeneration: number; + generations: Array<{ + generation: number; + stages: { creative: { outcome: string } }; + }>; + }; + const retained = generations as { first: number; second: number }; + expect(slot.latestGeneration).toBe(retained.second); + expect( + slot.generations.find((item) => item.generation === retained.first) + ?.stages.creative.outcome, + ).toBe("load_acknowledged"); + expect( + slot.generations.find((item) => item.generation === retained.second) + ?.stages.creative.outcome, + ).not.toBe("load_acknowledged"); + }); +}); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts new file mode 100644 index 000000000..09ceb53d5 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/ad-trace-gate.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; +import { runtimeUrl } from "../../helpers/state.js"; + +test("console query alone does not install ad trace when config is disabled", async ({ + page, +}) => { + await page.goto(runtimeUrl("/?ts_console=1"), { + waitUntil: "domcontentloaded", + }); + + await expect + .poll(() => + page.evaluate( + () => + typeof (window as Window & { tsjs?: { adTrace?: unknown } }) + .tsjs?.adTrace, + ), + ) + .toBe("undefined"); +}); diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml new file mode 100644 index 000000000..851d50cdf --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml @@ -0,0 +1,67 @@ +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration-admin-password-32-bytes-ok" + +[publisher] +domain = "localhost" +cookie_domain = "localhost" +origin_url = "http://127.0.0.1:8888" +proxy_secret = "integration-test-proxy-secret" + +[ec] +passphrase = "integration-test-ec-secret-padded-32" +ec_store = "ec_identity_store" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.ad_trace] +enabled = true + +[integrations.prebid] +enabled = true +server_url = "http://127.0.0.1:8888/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +timeout_ms = 750 +bidders = ["example-bidder"] +client_side_bidders = [] +debug = false +test_mode = true + +[integrations.gpt] +enabled = true +script_url = "https://ads.example.com/gpt.js" +cache_ttl_seconds = 3600 +rewrite_script = false + +[proxy] +certificate_check = false +allowed_domains = ["assets.example.com"] + +[auction] +enabled = true +providers = ["prebid"] +timeout_ms = 1000 +allowed_context_keys = [] + +[creative_opportunities] +gam_network_id = "123456789" +auction_timeout_ms = 750 +price_granularity = "dense" + +[[creative_opportunities.slot]] +id = "ad-trace-slot" +div_id = "ad-trace-slot" +gam_unit_path = "/123456789/example/ad-trace" +page_patterns = ["/", "/spa*"] +formats = [{ width = 300, height = 250 }] + +[creative_opportunities.slot.providers.prebid] +bidders = { example-bidder = { placement = "example-placement" } } + +[debug] +ja4_endpoint_enabled = false +inject_adm_for_testing = true diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile new file mode 100644 index 000000000..7996d6fde --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile @@ -0,0 +1,15 @@ +# Deterministic publisher/PBS fixture for the tester-only ad trace journey. +FROM php:8.3-cli-alpine + +WORKDIR /var/www/html + +COPY crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/ /var/www/html/ +COPY target/integration-test-artifacts/prebid/ /opt/prebid/ + +RUN bundle="$(find /opt/prebid -maxdepth 1 -name 'trusted-prebid-*.js' -type f | head -n 1)" \ + && test -n "$bundle" \ + && cp "$bundle" /var/www/html/prebid-bundle.js + +EXPOSE 80 + +CMD ["php", "-S", "0.0.0.0:80", "router.php"] diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php new file mode 100644 index 000000000..e7bfca062 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php @@ -0,0 +1,201 @@ + + + + + + Trusted Server ad trace fixture + + + + +

Ad trace contract fixture

+

This page uses deterministic local PBS, GPT, and universal creative protocol mocks.

+
+ + diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php new file mode 100644 index 000000000..e14e7b6e8 --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/router.php @@ -0,0 +1,50 @@ + $imp) { + $slotId = is_string($imp['id'] ?? null) ? $imp['id'] : 'ad-trace-slot'; + $bids[] = [ + 'id' => 'example-bid-' . ($index + 1), + 'impid' => $slotId, + 'adid' => 'example-ad-' . ($index + 1), + 'price' => 1.25, + 'adm' => '
Example creative loaded
', + 'crid' => 'example-creative-' . ($index + 1), + 'w' => 300, + 'h' => 250, + 'adomain' => ['advertiser.example.com'], + ]; + } + + header('Content-Type: application/json'); + echo json_encode([ + 'id' => is_string($request['id'] ?? null) ? $request['id'] : 'example-auction', + 'seatbid' => $bids ? [['seat' => 'example-bidder', 'bid' => $bids]] : [], + 'cur' => 'USD', + ], JSON_UNESCAPED_SLASHES); + return; +} + +if ($path === '/prebid-bundle.js') { + header('Content-Type: application/javascript'); + readfile(__DIR__ . '/prebid-bundle.js'); + return; +} + +if ($path === '/' || $path === '/spa-one' || $path === '/spa-two') { + require __DIR__ . '/index.php'; + return; +} + +http_response_code(404); +header('Content-Type: text/plain'); +echo 'Not found'; diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index e85b1d8d1..e41d84dc9 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -44,6 +44,9 @@ fn test_settings() -> Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" + + [integrations.ad_trace] + enabled = true "#, ) .expect("should parse parity test settings") @@ -85,6 +88,24 @@ async fn axum_get(uri: &str) -> (u16, HeaderMap) { (resp.status().as_u16(), resp.headers().clone()) } +async fn axum_document_get(uri: &str) -> (u16, HeaderMap) { + let mut svc = EdgeZeroAxumService::new(axum_router()); + let req = AxumRequest::builder() + .method("GET") + .uri(uri) + .header("sec-fetch-dest", "document") + .body(AxumBody::empty()) + .expect("should build document GET request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + /// Send a POST request to the Axum adapter and return (status, headers, body bytes). async fn axum_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { use http_body_util::BodyExt as _; @@ -131,6 +152,17 @@ async fn cf_get(uri: &str) -> (u16, HeaderMap) { (resp.status().as_u16(), resp.headers().clone()) } +async fn cf_document_get(uri: &str) -> (u16, HeaderMap) { + let req = request_builder() + .method("GET") + .uri(uri) + .header("sec-fetch-dest", "document") + .body(edgezero_core::body::Body::empty()) + .expect("should build document GET request"); + let resp = cf_router().oneshot(req).await.expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + /// Send a POST request to the Cloudflare adapter and return (status, headers, body bytes). async fn cf_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { let router = cf_router(); @@ -174,6 +206,17 @@ async fn spin_get(uri: &str) -> (u16, HeaderMap) { (s, h) } +async fn spin_document_get(uri: &str) -> (u16, HeaderMap) { + let req = request_builder() + .method("GET") + .uri(uri) + .header("sec-fetch-dest", "document") + .body(edgezero_core::body::Body::empty()) + .expect("should build document GET request"); + let resp = spin_router().oneshot(req).await.expect("should respond"); + (resp.status().as_u16(), resp.headers().clone()) +} + /// Send a POST request to the Spin adapter and return (status, headers, body bytes). async fn spin_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { let router = spin_router(); @@ -456,6 +499,34 @@ async fn verify_signature_route_parity() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn console_activation_finalizes_auth_short_circuits() { + let uri = "/_ts/admin/keys/rotate?ts_console=1"; + let responses = [ + axum_document_get(uri).await, + cf_document_get(uri).await, + spin_document_get(uri).await, + ]; + + for (status, headers) in responses { + assert_eq!(status, 401); + assert_eq!( + headers + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("private, no-store") + ); + assert!( + headers + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| value.starts_with("__Host-ts-console=1;")), + "auth short-circuit should preserve the console session action" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn admin_rotate_unauthenticated_parity() { // Both adapters must return 401 for unauthenticated admin requests on the diff --git a/crates/trusted-server-js/lib/src/core/ad_trace.ts b/crates/trusted-server-js/lib/src/core/ad_trace.ts new file mode 100644 index 000000000..aca037f5d --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/ad_trace.ts @@ -0,0 +1,505 @@ +import type { + AdTraceApi, + AdTraceConfidence, + AdTraceEvent, + AdTraceEventKind, + AdTraceExport, + AdTraceObservation, + AdTraceStage, + AdTraceStageName, + GenerationTraceSnapshot, + RenderTraceOutcome, + RenderTraceSnapshot, + RenderTraceVisibility, + SlotTraceSnapshot, +} from './types'; + +export const AD_TRACE_MAX_EVENTS = 256; +export const AD_TRACE_MAX_SLOTS = 64; +export const AD_TRACE_MAX_GENERATIONS = 8; +export const AD_TRACE_MAX_RENDERS = 200; +export const AD_TRACE_ACK_TTL_MS = 30_000; +const AD_TRACE_MAX_LISTENERS = 32; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const LABEL_RE = /^[\w.-]{1,64}$/; +const EVENT_KINDS = new Set([ + 'ts_auction_observed', + 'ts_winner_observed', + 'prebid_auction_init', + 'prebid_bid_response', + 'prebid_targeting_selected', + 'prebid_bid_won', + 'prebid_auction_end', + 'prebid_render_succeeded', + 'prebid_render_failed', + 'gpt_targeting_applied', + 'gpt_request_started', + 'gpt_slot_requested', + 'gpt_slot_response_received', + 'gpt_slot_render_ended', + 'gpt_slot_onload', + 'aps_display_bids_set', + 'pb_render_requested', + 'pb_render_rejected', + 'pb_render_served', + 'direct_render_rejected', + 'creative_load_acknowledged', + 'generation_superseded', +]); +const CONFIDENCES = new Set(['definitive', 'strong', 'probable', 'none']); +const EMPTY_STAGE: AdTraceStage = { outcome: 'not_observed', confidence: 'none', reason: 'none' }; + +type MutableGeneration = GenerationTraceSnapshot; +interface MutableSlot { + slotId: string; + latestGeneration: number; + baseStages: Record; + generations: MutableGeneration[]; +} + +export interface AdTraceStore extends AdTraceApi { + record(observation: AdTraceObservation): void; + nextGeneration(slotId: string): number; + subscribe(listener: () => void): () => void; + bindElement(slotId: string, generation: number, element: HTMLElement): void; + getBoundElement(slotId: string, generation: number): HTMLElement | undefined; + updateVisibility(slotId: string, generation: number, visibility: RenderTraceVisibility): void; +} + +function stages(): Record { + return { + trustedServer: { ...EMPTY_STAGE }, + prebid: { ...EMPTY_STAGE }, + gam: { ...EMPTY_STAGE }, + creative: { ...EMPTY_STAGE }, + }; +} + +function safeLabel(value: unknown): string | undefined { + return typeof value === 'string' && LABEL_RE.test(value) ? value : undefined; +} +function safeUuid(value: unknown): string | undefined { + return typeof value === 'string' && UUID_RE.test(value) ? value : undefined; +} +function cloneStages(value: Record) { + return Object.fromEntries( + Object.entries(value).map(([key, stage]) => [key, { ...stage }]) + ) as Record; +} +function cloneFreeze(value: T): T { + const clone = JSON.parse(JSON.stringify(value)) as T; + const freeze = (item: unknown): void => { + if (!item || typeof item !== 'object' || Object.isFrozen(item)) return; + Object.freeze(item); + Object.values(item as Record).forEach(freeze); + }; + freeze(clone); + return clone; +} +function newSlot(slotId: string): MutableSlot { + return { slotId, latestGeneration: 0, baseStages: stages(), generations: [] }; +} + +function updateStage(target: Record, event: AdTraceEvent): void { + const explicit = event.outcome + ? { + outcome: event.outcome, + confidence: event.confidence ?? 'none', + reason: event.reason ?? 'observed', + } + : undefined; + switch (event.kind) { + case 'ts_winner_observed': + target.trustedServer = { + outcome: 'won', + confidence: 'definitive', + reason: 'final_server_winner', + }; + break; + case 'ts_auction_observed': + target.trustedServer = explicit ?? { + outcome: 'unresolved', + confidence: 'none', + reason: 'terminal_summary', + }; + break; + case 'prebid_targeting_selected': + target.prebid = explicit ?? { + outcome: event.bidTraceId ? 'won' : 'client_bid_won', + confidence: 'definitive', + reason: 'selected_targeting', + }; + break; + case 'prebid_auction_end': + if (explicit && target.prebid.confidence !== 'definitive') target.prebid = explicit; + break; + case 'prebid_bid_won': + if (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') { + target.prebid = { + ...target.prebid, + reason: 'selected_targeting_with_bid_won', + }; + if (target.gam.outcome === 'direct_or_unattributed') { + target.gam = { + outcome: 'client_prebid_candidate', + confidence: 'probable', + reason: 'client_bid_won_and_gpt_rendered', + }; + } + } + break; + case 'prebid_render_succeeded': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'prebid_render_succeeded', + confidence: 'strong', + reason: event.reason ?? 'prebid_render_succeeded', + }; + } + break; + case 'prebid_render_failed': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'render_failed', + confidence: 'definitive', + reason: event.reason ?? 'prebid_render_failed', + }; + } + break; + case 'gpt_slot_render_ended': + // Cooperative acknowledgement is stronger than later GPT callbacks and + // must never be downgraded to a probable candidate. + if (target.gam.confidence === 'definitive') break; + if (explicit?.outcome === 'unresolved') target.gam = explicit; + else if (event.isEmpty) + target.gam = { outcome: 'empty', confidence: 'definitive', reason: 'gpt_empty' }; + else if (event.isBackfill) + target.gam = { outcome: 'backfill', confidence: 'definitive', reason: 'gpt_backfill' }; + else if (event.bidTraceId) + target.gam = { + outcome: 'trusted_server_candidate', + confidence: 'probable', + reason: 'trace_targeting_rendered', + }; + else if ( + (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') && + target.prebid.reason === 'selected_targeting_with_bid_won' + ) + target.gam = { + outcome: 'client_prebid_candidate', + confidence: 'probable', + reason: 'client_bid_won_and_gpt_rendered', + }; + else + target.gam = { + outcome: 'direct_or_unattributed', + confidence: 'probable', + reason: 'non_empty_unattributed', + }; + break; + case 'aps_display_bids_set': + // APS setting display bids is a handoff only. GAM attribution remains + // unobserved until a correlated non-empty GPT render arrives. + break; + case 'gpt_slot_onload': + if (target.creative.outcome === 'not_observed') + target.creative = { + outcome: 'gpt_iframe_onload', + confidence: 'probable', + reason: 'gpt_slot_onload', + }; + break; + case 'pb_render_served': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'renderer_served', + confidence: 'strong', + reason: event.reason ?? 'pb_render_response', + }; + } + break; + case 'direct_render_rejected': + if (target.creative.confidence === 'none') { + target.creative = { + outcome: 'rejected', + confidence: 'none', + reason: event.reason ?? 'direct_render_rejected', + }; + } + break; + case 'creative_load_acknowledged': + target.creative = { + outcome: 'load_acknowledged', + confidence: 'definitive', + reason: 'source_validated_load', + }; + if (event.reason !== 'direct_iframe_load') { + target.gam = { + outcome: 'trusted_server_won', + confidence: 'definitive', + reason: 'creative_load_acknowledged', + }; + } + break; + case 'generation_superseded': + // Ownership cleanup is lifecycle evidence, not contradictory render + // evidence. Preserve every previously observed stage unchanged. + break; + default: + break; + } +} + +function snapshot(slot: MutableSlot): SlotTraceSnapshot { + const latest = slot.generations.at(-1); + return { + slotId: slot.slotId, + latestGeneration: slot.latestGeneration, + generations: slot.generations.map((item) => ({ + generation: item.generation, + stages: cloneStages(item.stages), + })), + stages: cloneStages(latest?.stages ?? slot.baseStages), + }; +} + +function isRenderEvent(kind: AdTraceEventKind): boolean { + return ( + kind === 'gpt_request_started' || + kind === 'gpt_slot_render_ended' || + kind === 'prebid_render_succeeded' || + kind === 'prebid_render_failed' || + kind === 'pb_render_requested' || + kind === 'pb_render_rejected' || + kind === 'pb_render_served' || + kind === 'direct_render_rejected' || + kind === 'creative_load_acknowledged' || + kind === 'generation_superseded' + ); +} + +function renderSource(event: AdTraceEvent): RenderTraceSnapshot['source'] { + if (event.reason?.startsWith('direct_')) return 'direct_auction'; + if (event.kind.startsWith('pb_render_') || event.kind === 'creative_load_acknowledged') + return 'pb_render'; + return 'gpt'; +} + +function renderOutcome( + current: Pick, + event: AdTraceEvent +): { outcome: RenderTraceOutcome; confidence: AdTraceConfidence } { + if (current.outcome === 'confirmed') return { outcome: 'confirmed', confidence: 'definitive' }; + if (current.outcome === 'empty' && current.confidence === 'definitive') { + return { outcome: 'empty', confidence: 'definitive' }; + } + if (event.kind === 'creative_load_acknowledged') + return { outcome: 'confirmed', confidence: 'definitive' }; + if (current.outcome === 'served') return { outcome: 'served', confidence: 'strong' }; + if (event.kind === 'pb_render_served') return { outcome: 'served', confidence: 'strong' }; + if (event.kind === 'gpt_slot_render_ended') + return event.isEmpty + ? { outcome: 'empty', confidence: 'definitive' } + : { outcome: 'gam_only', confidence: 'probable' }; + if (current.outcome === 'gam_only') return { outcome: 'gam_only', confidence: 'probable' }; + return { outcome: 'unresolved', confidence: 'none' }; +} + +export function createAdTraceStore( + now: () => number = () => (typeof performance === 'undefined' ? Date.now() : performance.now()) +): AdTraceStore { + const slots = new Map(); + const events: AdTraceEvent[] = []; + const renders: RenderTraceSnapshot[] = []; + const renderByGeneration = new Map(); + const elementByGeneration = new Map(); + const listeners = new Set<() => void>(); + let sequence = 0; + let generationSequence = 0; + let renderSequence = 0; + let droppedEvents = 0; + let evictedSlots = 0; + const ensureSlot = (slotId: string): MutableSlot => { + let slot = slots.get(slotId); + if (slot) return slot; + if (slots.size >= AD_TRACE_MAX_SLOTS) { + const oldest = slots.keys().next().value as string | undefined; + if (oldest) { + slots.delete(oldest); + evictedSlots += 1; + } + } + slot = newSlot(slotId); + slots.set(slotId, slot); + return slot; + }; + const notify = (): void => listeners.forEach((listener) => listener()); + const emitRender = (render: RenderTraceSnapshot): void => { + if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return; + window.dispatchEvent(new CustomEvent('tsjs:adRendered', { detail: cloneFreeze(render) })); + }; + const updateRender = (event: AdTraceEvent, slotId: string, generation: number): void => { + if (!isRenderEvent(event.kind)) return; + const key = `${slotId}:${generation}`; + let render = renderByGeneration.get(key); + const timestamp = now(); + if (!render) { + render = { + sequence: ++renderSequence, + slotId, + generation, + source: renderSource(event), + outcome: 'unresolved', + confidence: 'none', + visibility: 'unknown', + createdAt: timestamp, + updatedAt: timestamp, + }; + renderByGeneration.set(key, render); + renders.push(render); + if (renders.length > AD_TRACE_MAX_RENDERS) { + const evicted = renders.shift(); + if (evicted) { + const evictedKey = `${evicted.slotId}:${evicted.generation}`; + renderByGeneration.delete(evictedKey); + elementByGeneration.delete(evictedKey); + } + } + } + const next = renderOutcome(render, event); + render.outcome = next.outcome; + render.confidence = next.confidence; + if (event.reason?.startsWith('direct_')) render.source = 'direct_auction'; + else if (event.kind.startsWith('pb_render_') || event.kind === 'creative_load_acknowledged') + render.source = render.source === 'direct_auction' ? render.source : 'pb_render'; + if (event.auctionTraceId) render.auctionTraceId = event.auctionTraceId; + if (event.bidTraceId) render.bidTraceId = event.bidTraceId; + render.updatedAt = timestamp; + emitRender(render); + }; + + return { + record(observation) { + if (!EVENT_KINDS.has(observation.kind)) return; + if (observation.confidence && !CONFIDENCES.has(observation.confidence)) return; + const slotId = safeLabel(observation.slotId); + const generation = + Number.isInteger(observation.generation) && (observation.generation ?? 0) > 0 + ? observation.generation + : undefined; + const event: AdTraceEvent = { + sequence: ++sequence, + timestamp: now(), + kind: observation.kind, + ...(slotId ? { slotId } : {}), + ...(generation ? { generation } : {}), + ...(safeUuid(observation.auctionTraceId) + ? { auctionTraceId: observation.auctionTraceId } + : {}), + ...(safeUuid(observation.bidTraceId) ? { bidTraceId: observation.bidTraceId } : {}), + ...(safeLabel(observation.provider) ? { provider: observation.provider } : {}), + ...(safeLabel(observation.bidder) ? { bidder: observation.bidder } : {}), + ...(safeLabel(observation.outcome) ? { outcome: observation.outcome } : {}), + ...(observation.confidence ? { confidence: observation.confidence } : {}), + ...(safeLabel(observation.reason) ? { reason: observation.reason } : {}), + ...(typeof observation.isEmpty === 'boolean' ? { isEmpty: observation.isEmpty } : {}), + ...(typeof observation.isBackfill === 'boolean' + ? { isBackfill: observation.isBackfill } + : {}), + }; + events.push(event); + if (events.length > AD_TRACE_MAX_EVENTS) { + events.shift(); + droppedEvents += 1; + } + if (slotId) { + const slot = ensureSlot(slotId); + const exact = generation + ? slot.generations.find((item) => item.generation === generation) + : undefined; + if (exact) updateStage(exact.stages, event); + else if ( + !generation && + (event.kind === 'ts_winner_observed' || event.kind === 'ts_auction_observed') + ) { + // Generationless server evidence seeds only the next request. Updating + // the latest retained generation would rewrite prior-navigation history. + updateStage(slot.baseStages, event); + } + if (generation) updateRender(event, slotId, generation); + } + notify(); + }, + nextGeneration(slotId) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId) return 0; + const slot = ensureSlot(safeSlotId); + slot.latestGeneration = ++generationSequence; + slot.generations.push({ + generation: slot.latestGeneration, + stages: cloneStages(slot.baseStages), + }); + if (slot.generations.length > AD_TRACE_MAX_GENERATIONS) slot.generations.shift(); + notify(); + return slot.latestGeneration; + }, + getSlot(slotId) { + const slot = slots.get(slotId); + return slot ? cloneFreeze(snapshot(slot)) : undefined; + }, + getEvents() { + return cloneFreeze(events); + }, + getRenderTimeline() { + return cloneFreeze(renders); + }, + export() { + const value: AdTraceExport = { + version: 1, + slots: [...slots.values()].map(snapshot), + events, + renders, + metadata: { droppedEvents, evictedSlots }, + }; + return cloneFreeze(value); + }, + subscribe(listener) { + if (listeners.size >= AD_TRACE_MAX_LISTENERS) return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + bindElement(slotId, generation, element) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId || !Number.isInteger(generation) || generation <= 0) return; + const key = `${safeSlotId}:${generation}`; + if (!elementByGeneration.has(key) && elementByGeneration.size >= AD_TRACE_MAX_RENDERS) { + const oldest = elementByGeneration.keys().next().value as string | undefined; + if (oldest) elementByGeneration.delete(oldest); + } + elementByGeneration.set(key, element); + }, + getBoundElement(slotId, generation) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId || !Number.isInteger(generation) || generation <= 0) return undefined; + return elementByGeneration.get(`${safeSlotId}:${generation}`); + }, + updateVisibility(slotId, generation, visibility) { + const safeSlotId = safeLabel(slotId); + if (!safeSlotId || !Number.isInteger(generation) || generation <= 0) return; + const render = renderByGeneration.get(`${safeSlotId}:${generation}`); + if (!render || render.visibility === visibility) return; + render.visibility = visibility; + render.updatedAt = now(); + emitRender(render); + notify(); + }, + }; +} + +export function isCanonicalTraceUuid(value: unknown): value is string { + return safeUuid(value) !== undefined; +} +export function isBoundedTraceLabel(value: unknown): value is string { + return safeLabel(value) !== undefined; +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index a02684362..50b87c955 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -5,7 +5,13 @@ import { parseApsRendererDescriptor } from '../integrations/aps/render'; import { log } from './log'; -import type { ApsRendererV1 } from './types'; +import type { + ApsRendererV1, + AuctionTraceOutcome, + AuctionTraceSource, + AuctionTraceSummary, + TrustedServerBidTrace, +} from './types'; // --------------------------------------------------------------------------- // Types @@ -41,6 +47,11 @@ export interface AdRequest { } /** A parsed bid from an OpenRTB seatbid response. */ +export type AuctionClientResult = + | { kind: 'ok'; summary?: AuctionTraceSummary; bids: AuctionBid[] } + | { kind: 'transport_error'; reason: 'network' | 'http' } + | { kind: 'invalid_response'; reason: 'non_json' | 'invalid_shape' }; + export interface AuctionBid { /** Matches the `impid` in the response — corresponds to adUnit `code`. */ impid: string; @@ -60,6 +71,72 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; + /** Tester-gated trace joined to the validated root summary. */ + trace?: TrustedServerBidTrace; +} + +const TRACE_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const TRACE_LABEL_RE = /^[\w.-]{1,64}$/; +const TRACE_SOURCES = new Set([ + 'initial_navigation', + 'spa_navigation', + 'auction_api', +]); +const TRACE_OUTCOMES = new Set([ + 'completed', + 'no_bid', + 'skipped', + 'failed', + 'abandoned', +]); + +/** Strictly parse the optional Trusted Server root extension. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseAuctionTraceSummary(body: any): AuctionTraceSummary | undefined { + const trace = body?.ext?.trusted_server?.trace; + if ( + trace?.version !== 1 || + !TRACE_UUID_RE.test(trace.auction_trace_id) || + !TRACE_SOURCES.has(trace.source) || + !TRACE_OUTCOMES.has(trace.outcome) + ) { + return undefined; + } + return { + version: 1, + auctionTraceId: trace.auction_trace_id, + source: trace.source, + outcome: trace.outcome, + }; +} + +function parseBidTrace( + bid: any, // eslint-disable-line @typescript-eslint/no-explicit-any + root: AuctionTraceSummary | undefined +): TrustedServerBidTrace | undefined { + const trace = bid?.ext?.trusted_server?.trace; + if ( + !root || + root.outcome !== 'completed' || + trace?.version !== 1 || + !TRACE_UUID_RE.test(trace.bid_trace_id) || + typeof trace.slot_id !== 'string' || + trace.slot_id !== bid?.impid || + !TRACE_LABEL_RE.test(trace.slot_id) || + !TRACE_LABEL_RE.test(trace.provider) || + !TRACE_LABEL_RE.test(trace.bidder) + ) { + return undefined; + } + return { + version: 1, + auctionTraceId: root.auctionTraceId, + bidTraceId: trace.bid_trace_id, + source: root.source, + slotId: trace.slot_id, + provider: trace.provider, + bidder: trace.bidder, + }; } // --------------------------------------------------------------------------- @@ -126,6 +203,7 @@ export function buildAdRequest(units: any[], options?: { eids?: AuctionEid[] }): // eslint-disable-next-line @typescript-eslint/no-explicit-any export function parseAuctionResponse(body: any): AuctionBid[] { const bids: AuctionBid[] = []; + const rootTrace = parseAuctionTraceSummary(body); const seatbids = body?.seatbid; if (!Array.isArray(seatbids)) return bids; @@ -142,6 +220,7 @@ export function parseAuctionResponse(body: any): AuctionBid[] { const creativeId = typeof bid?.crid === 'string' ? bid.crid : (renderer?.creativeId ?? `${seat}-${impid}`); + const trace = parseBidTrace(bid, rootTrace); bids.push({ impid, // Preserve non-string untrusted values so the render-time sanitizer @@ -157,25 +236,45 @@ export function parseAuctionResponse(body: any): AuctionBid[] { adomain: Array.isArray(bid?.adomain) ? bid.adomain.filter((domain: unknown): domain is string => typeof domain === 'string') : [], + ...(trace ? { trace } : {}), }); } } return bids; } +function isValidAuctionResponseShape(data: Record): boolean { + const seatbid = data.seatbid; + // Preserve the legacy valid empty response while rejecting a present but + // malformed collection that would otherwise be misreported as no-bid. + if (seatbid === undefined) return true; + if (!Array.isArray(seatbid)) return false; + return seatbid.every((seat) => { + if (!seat || typeof seat !== 'object' || Array.isArray(seat)) return false; + const bids = (seat as Record).bid; + return ( + bids === undefined || + (Array.isArray(bids) && + bids.every((bid) => !!bid && typeof bid === 'object' && !Array.isArray(bid))) + ); + }); +} + // --------------------------------------------------------------------------- // Auction HTTP call // --------------------------------------------------------------------------- /** - * POST an {@link AdRequest} to the given endpoint and return parsed bids. - * - * Returns an empty array on network or parse errors (non-throwing). + * POST an {@link AdRequest} and distinguish a valid empty auction from + * transport or response-shape failures. */ -export async function sendAuction(endpoint: string, request: AdRequest): Promise { +export async function sendAuction( + endpoint: string, + request: AdRequest +): Promise { if (typeof fetch !== 'function') { log.warn('auction: fetch not available'); - return []; + return { kind: 'transport_error', reason: 'network' }; } log.info('auction: sending request', { endpoint, units: request.adUnits.length }); @@ -190,21 +289,40 @@ export async function sendAuction(endpoint: string, request: AdRequest): Promise }); const contentType = response.headers.get('content-type') || ''; - if (response.ok && contentType.includes('application/json')) { - const data: unknown = await response.json(); - const bids = parseAuctionResponse(data); - log.info('auction: received bids', { count: bids.length }); - return bids; + if (!response.ok) { + log.warn('auction: unexpected response', { + ok: response.ok, + status: response.status, + ct: contentType, + }); + return { kind: 'transport_error', reason: 'http' }; + } + if (!contentType.includes('application/json')) { + log.warn('auction: non-json response', { status: response.status, ct: contentType }); + return { kind: 'invalid_response', reason: 'non_json' }; } - log.warn('auction: unexpected response', { - ok: response.ok, - status: response.status, - ct: contentType, - }); - return []; + let data: unknown; + try { + data = await response.json(); + } catch (error) { + log.warn('auction: invalid json response', error); + return { kind: 'invalid_response', reason: 'non_json' }; + } + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + !isValidAuctionResponseShape(data as Record) + ) { + return { kind: 'invalid_response', reason: 'invalid_shape' }; + } + const bids = parseAuctionResponse(data); + const summary = parseAuctionTraceSummary(data); + log.info('auction: received bids', { count: bids.length }); + return { kind: 'ok', ...(summary ? { summary } : {}), bids }; } catch (error) { log.warn('auction: request failed', error); - return []; + return { kind: 'transport_error', reason: 'network' }; } } diff --git a/crates/trusted-server-js/lib/src/core/global.d.ts b/crates/trusted-server-js/lib/src/core/global.d.ts index c7c8b08fb..2e753c6d9 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -2,6 +2,8 @@ import type { TsjsApi } from './types'; declare global { interface Window { + /** Request-scoped server bootstrap consumed synchronously by ad trace. */ + __tsjs_adTraceActive?: boolean; tsjs?: TsjsApi; pbjs?: TsjsApi; } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index ae352c58a..b7429d01c 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -6,6 +6,7 @@ import { collectContext } from './context'; import { log } from './log'; import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; +import type { AuctionTraceSummary, TrustedServerBidTrace } from './types'; export type RequestAdsCallback = () => void; export interface RequestAdsOptions { @@ -13,6 +14,16 @@ export interface RequestAdsOptions { timeout?: number; } +const MAX_DIRECT_RENDER_OWNERS = 64; + +interface DirectRenderOwner { + token: symbol; + slotId: string; + generation?: number; +} + +const latestDirectOwners = new Map(); + type RenderCreativeInlineOptions = { slotId: string; // Accept unknown input here because bidder JSON is untrusted at runtime. @@ -21,8 +32,64 @@ type RenderCreativeInlineOptions = { creativeHeight?: number; seat: string; creativeId: string; + owner: DirectRenderOwner; + trace?: TrustedServerBidTrace; }; +function claimDirectOwner(slotId: string): DirectRenderOwner { + const previous = latestDirectOwners.get(slotId); + if (previous) recordDirectRejection(previous, 'direct_owner_replaced'); + const ts = window.tsjs; + const generation = ts?.recordAdTrace ? ts.nextAdTraceGeneration?.(slotId) : undefined; + const owner: DirectRenderOwner = { + token: Symbol(slotId), + slotId, + ...(generation && generation > 0 ? { generation } : {}), + }; + latestDirectOwners.delete(slotId); + latestDirectOwners.set(slotId, owner); + if (latestDirectOwners.size > MAX_DIRECT_RENDER_OWNERS) { + const oldest = latestDirectOwners.keys().next().value as string | undefined; + if (oldest) { + const evicted = latestDirectOwners.get(oldest); + if (evicted) recordDirectRejection(evicted, 'direct_owner_evicted'); + latestDirectOwners.delete(oldest); + } + } + return owner; +} + +function ownerIsCurrent(owner: DirectRenderOwner): boolean { + return latestDirectOwners.get(owner.slotId) === owner; +} + +function recordRootSummary( + summary: AuctionTraceSummary | undefined, + owner: DirectRenderOwner, + hasWinner: boolean +): void { + if (!summary || !owner.generation) return; + window.tsjs?.recordAdTrace?.({ + kind: 'ts_auction_observed', + slotId: owner.slotId, + generation: owner.generation, + auctionTraceId: summary.auctionTraceId, + outcome: summary.outcome === 'completed' && !hasWinner ? 'no_bid' : summary.outcome, + confidence: 'definitive', + reason: 'terminal_summary', + }); +} + +function recordDirectRejection(owner: DirectRenderOwner, reason: string): void { + if (!owner.generation) return; + window.tsjs?.recordAdTrace?.({ + kind: 'direct_render_rejected', + slotId: owner.slotId, + generation: owner.generation, + reason, + }); +} + // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, @@ -41,38 +108,99 @@ export function requestAds( log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); try { const adUnits = getAllUnits(); + const requestedSlotIds = [ + ...new Set( + adUnits + .map((unit) => unit.code) + .filter((code): code is string => typeof code === 'string' && code.length > 0) + ), + ]; + const owners = new Map(requestedSlotIds.map((slotId) => [slotId, claimDirectOwner(slotId)])); const config = collectContext(); const payload = { ...buildAdRequest(adUnits), config }; log.debug('requestAds: payload', { units: adUnits.length, contextKeys: Object.keys(config) }); - // Use unified auction endpoint - void sendAuction('/auction', payload) - .then((bids) => { - log.info('requestAds: got bids', { count: bids.length }); - for (const bid of bids) { - if (!bid.impid) continue; - if (bid.renderer) { - renderApsCreative({ slotId: bid.impid, renderer: bid.renderer }); - continue; - } - if (!bid.adm) { - log.debug('requestAds: bid has no adm, skipping', { slotId: bid.impid }); - continue; + void sendAuction('/auction', payload).then((result) => { + if (result.kind !== 'ok') { + for (const owner of owners.values()) { + if (ownerIsCurrent(owner)) { + recordDirectRejection(owner, `${result.kind}_${result.reason}`); } - renderCreativeInline({ - slotId: bid.impid, - creativeHtml: bid.adm, - creativeWidth: bid.width, - creativeHeight: bid.height, - seat: bid.seat, - creativeId: bid.creativeId, + } + return; + } + + log.info('requestAds: got bids', { count: result.bids.length }); + const bySlot = new Map(); + for (const bid of result.bids) { + if (!owners.has(bid.impid)) continue; + const existing = bySlot.get(bid.impid) ?? []; + existing.push(bid); + bySlot.set(bid.impid, existing); + } + + for (const [slotId, owner] of owners) { + if (!ownerIsCurrent(owner)) continue; + const slotBids = bySlot.get(slotId) ?? []; + recordRootSummary(result.summary, owner, slotBids.length > 0); + if (slotBids.length === 0) continue; + if (slotBids.length !== 1) { + recordDirectRejection(owner, 'ambiguous_winner'); + continue; + } + + const bid = slotBids[0]; + const trace = + bid.trace && + result.summary && + bid.trace.slotId === slotId && + bid.trace.auctionTraceId === result.summary.auctionTraceId + ? bid.trace + : undefined; + if (trace && owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'ts_winner_observed', + slotId, + generation: owner.generation, + auctionTraceId: trace.auctionTraceId, + bidTraceId: trace.bidTraceId, + provider: trace.provider, + bidder: trace.bidder, }); } - log.info('requestAds: rendered creatives from response'); - }) - .catch((err) => { - log.warn('requestAds: auction failed', err); - }); + if (bid.renderer) { + if (!ownerIsCurrent(owner)) continue; + if (!renderApsCreative({ slotId, renderer: bid.renderer })) { + recordDirectRejection(owner, 'aps_render_rejected'); + } else if (owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: owner.generation, + auctionTraceId: trace?.auctionTraceId, + bidTraceId: trace?.bidTraceId, + reason: 'direct_aps_renderer', + }); + } + continue; + } + if (!bid.adm) { + recordDirectRejection(owner, 'missing_adm'); + continue; + } + renderCreativeInline({ + slotId, + creativeHtml: bid.adm, + creativeWidth: bid.width, + creativeHeight: bid.height, + seat: bid.seat, + creativeId: bid.creativeId, + owner, + ...(trace ? { trace } : {}), + }); + } + log.info('requestAds: rendered creatives from response'); + }); // Synchronously invoke callback to match test expectations try { @@ -93,16 +221,24 @@ function renderCreativeInline({ creativeHeight, seat, creativeId, + owner, + trace, }: RenderCreativeInlineOptions): void { + if (!ownerIsCurrent(owner)) return; const container = findSlot(slotId) as HTMLElement | null; if (!container) { + recordDirectRejection(owner, 'slot_missing'); log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); return; } try { + if (owner.generation) { + window.tsjs?.bindAdTraceElement?.(slotId, owner.generation, container); + } const sanitization = sanitizeCreativeHtml(creativeHtml); if (sanitization.kind === 'rejected') { + recordDirectRejection(owner, 'creative_rejected'); log.warn('renderCreativeInline: rejected creative', { slotId, seat, @@ -113,6 +249,7 @@ function renderCreativeInline({ return; } + if (!ownerIsCurrent(owner)) return; // Clear the slot only after sanitization succeeds so rejected creatives never blank existing content. container.innerHTML = ''; @@ -138,8 +275,36 @@ function renderCreativeInline({ width, height, }); + iframe.addEventListener( + 'load', + () => { + if (!ownerIsCurrent(owner) || !iframe.isConnected || iframe.parentElement !== container) + return; + if (owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'creative_load_acknowledged', + slotId, + generation: owner.generation, + auctionTraceId: trace?.auctionTraceId, + bidTraceId: trace?.bidTraceId, + reason: 'direct_iframe_load', + }); + } + }, + { once: true } + ); iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); + if (owner.generation) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: owner.generation, + auctionTraceId: trace?.auctionTraceId, + bidTraceId: trace?.bidTraceId, + reason: 'direct_iframe_created', + }); + } log.info('renderCreativeInline: rendered', { slotId, @@ -150,6 +315,7 @@ function renderCreativeInline({ originalLength: sanitization.originalLength, }); } catch (err) { + recordDirectRejection(owner, 'render_failed'); log.warn('renderCreativeInline: failed', { slotId, seat, creativeId, err }); } } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 7b81e78a6..0ba40fd5d 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -80,6 +80,126 @@ export interface ApsPrebidRendererEntry { markRendered(): void; } +export type AuctionTraceSource = 'initial_navigation' | 'spa_navigation' | 'auction_api'; +export type AuctionTraceOutcome = 'completed' | 'no_bid' | 'skipped' | 'failed' | 'abandoned'; + +/** Privacy-safe summary emitted only for configured tester traffic. */ +export interface AuctionTraceSummary { + version: 1; + auctionTraceId: string; + source: AuctionTraceSource; + outcome: AuctionTraceOutcome; +} + +/** Privacy-safe trace for one final Trusted Server winning bid. */ +export interface TrustedServerBidTrace { + version: 1; + auctionTraceId: string; + bidTraceId: string; + source: AuctionTraceSource; + slotId: string; + provider: string; + bidder: string; +} + +export type AdTraceConfidence = 'definitive' | 'strong' | 'probable' | 'none'; +export type AdTraceStageName = 'trustedServer' | 'prebid' | 'gam' | 'creative'; +export interface AdTraceStage { + outcome: string; + confidence: AdTraceConfidence; + reason: string; +} + +export type AdTraceEventKind = + | 'ts_auction_observed' + | 'ts_winner_observed' + | 'prebid_auction_init' + | 'prebid_bid_response' + | 'prebid_targeting_selected' + | 'prebid_bid_won' + | 'prebid_auction_end' + | 'prebid_render_succeeded' + | 'prebid_render_failed' + | 'gpt_targeting_applied' + | 'gpt_request_started' + | 'gpt_slot_requested' + | 'gpt_slot_response_received' + | 'gpt_slot_render_ended' + | 'gpt_slot_onload' + | 'aps_display_bids_set' + | 'pb_render_requested' + | 'pb_render_rejected' + | 'pb_render_served' + | 'direct_render_rejected' + | 'creative_load_acknowledged' + | 'generation_superseded'; + +/** Sanitized observation accepted by the optional recorder. */ +export interface AdTraceObservation { + kind: AdTraceEventKind; + slotId?: string; + generation?: number; + auctionTraceId?: string; + bidTraceId?: string; + provider?: string; + bidder?: string; + outcome?: string; + confidence?: AdTraceConfidence; + reason?: string; + isEmpty?: boolean; + isBackfill?: boolean; +} + +export interface AdTraceEvent extends AdTraceObservation { + sequence: number; + timestamp: number; +} + +export interface GenerationTraceSnapshot { + generation: number; + stages: Record; +} + +export interface SlotTraceSnapshot { + slotId: string; + latestGeneration: number; + generations: GenerationTraceSnapshot[]; + /** Convenience view of only the latest retained generation. */ + stages: Record; +} + +export type RenderTraceOutcome = 'confirmed' | 'served' | 'gam_only' | 'empty' | 'unresolved'; +export type RenderTraceVisibility = 'visible' | 'hidden' | 'disconnected' | 'unknown'; + +export interface RenderTraceSnapshot { + sequence: number; + slotId: string; + generation: number; + auctionTraceId?: string; + bidTraceId?: string; + source: 'gpt' | 'pb_render' | 'direct_auction'; + outcome: RenderTraceOutcome; + confidence: AdTraceConfidence; + visibility: RenderTraceVisibility; + createdAt: number; + updatedAt: number; +} + +export interface AdTraceExport { + version: 1; + slots: SlotTraceSnapshot[]; + events: AdTraceEvent[]; + renders: RenderTraceSnapshot[]; + metadata: { droppedEvents: number; evictedSlots: number }; +} + +export interface AdTraceApi { + getSlot(slotId: string): SlotTraceSnapshot | undefined; + getEvents(): readonly AdTraceEvent[]; + getRenderTimeline(): readonly RenderTraceSnapshot[]; + export(): AdTraceExport; +} + /** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ export interface AuctionBidData { hb_pb?: string; @@ -95,6 +215,8 @@ export interface AuctionBidData { burl?: string; /** Typed winning-bid renderer capability. */ renderer?: AuctionBidRenderer; + /** Tester-gated trace; absent for ordinary traffic and malformed input. */ + trace?: TrustedServerBidTrace; /** * Sanitized winning creative markup for local rendering through the pbRender * bridge. Present whenever the winning bid carried a creative that passed the @@ -145,6 +267,80 @@ export interface TsjsApi { * `hb_adid`. The Universal Creative bridge consumes each entry at most once. */ apsPrebidRenderers?: Record; + /** Tester-gated terminal auction summary. */ + auctionTrace?: AuctionTraceSummary; + /** Tester-only immutable diagnostic API. */ + adTrace?: AdTraceApi; + /** Private recorder installed only by the optional ad_trace module. */ + recordAdTrace?: (observation: AdTraceObservation) => void; + /** Private generation allocator installed only by the optional module. */ + nextAdTraceGeneration?: (slotId: string) => number; + /** Private overlay subscription installed only by the optional module. */ + subscribeAdTrace?: (listener: () => void) => () => void; + /** Bind one generation to the exact DOM element captured at its request boundary. */ + bindAdTraceElement?: (slotId: string, generation: number, element: HTMLElement) => void; + /** Resolve only that exact captured element; never searches replacement DOM. */ + getAdTraceElement?: (slotId: string, generation: number) => HTMLElement | undefined; + /** Private live visibility updater used only by the active overlay. */ + updateAdTraceVisibility?: ( + slotId: string, + generation: number, + visibility: RenderTraceVisibility + ) => void; + /** Private request-scoped Prebid correlation ledger; never exported. */ + prebidCorrelation?: Array<{ + auctionId: string; + slotId: string; + requestId: string; + bidder?: string; + adId?: string; + traceToken?: string; + serverTrace?: TrustedServerBidTrace; + events?: AdTraceEventKind[]; + }>; + /** Exact selected participants retained briefly for post-request terminal events. */ + prebidSelectedParticipants?: Array<{ + auctionId: string; + slotId: string; + requestId: string; + adId?: string; + traceToken?: string; + bidder?: string; + generation: number; + selectedAt: number; + }>; + /** Request-scoped root summaries retained until the GPT request boundary. */ + prebidServerSummaries?: Array<{ + auctionId: string; + slotId: string; + summary: AuctionTraceSummary; + }>; + /** Completed Prebid auctions used to identify request-scoped no-bid selections. */ + prebidCompletedAuctions?: Array<{ auctionId: string; slotIds: string[] }>; + /** Private bootstrap queue used until the GPT module installs its capture hook. */ + pendingAdTraceRequests?: Array<{ + slot: unknown; + trigger: string; + snapshot?: { + slotId?: string; + bidder?: string; + adId?: string; + traceToken?: string; + bid?: AuctionBidData; + }; + }>; + /** Private request-boundary hook shared with bootstrap and slim Prebid. */ + captureAdTraceRequest?: ( + slot: unknown, + trigger: string, + snapshot?: { + slotId?: string; + bidder?: string; + adId?: string; + traceToken?: string; + bid?: AuctionBidData; + } + ) => number; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ adInit?: () => void; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ @@ -153,12 +349,6 @@ export interface TsjsApi { servicesEnabled?: boolean; /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ divToSlotId?: Record; - /** - * Win/billing beacons already fired, keyed by `slotId|bidIdentity|kind|url`. - * Used by the GPT render bridge so a bid's nurl/burl fire at most once even - * across repeated Prebid Universal Creative requests for the same adId. - */ - firedBeacons?: Record; /** Slot-level GPT targeting keys TS applied on the previous route. */ prevSlotTargetingKeys?: Record; /** diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts new file mode 100644 index 000000000..cf6d6d33c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts @@ -0,0 +1,98 @@ +import { createAdTraceStore, isBoundedTraceLabel, isCanonicalTraceUuid } from '../../core/ad_trace'; +import type { AdTraceApi, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; + +import { installAdTraceOverlay } from './overlay'; + +const TRACE_SOURCES = new Set(['initial_navigation', 'spa_navigation', 'auction_api']); +const TRACE_OUTCOMES = new Set(['completed', 'no_bid', 'skipped', 'failed', 'abandoned']); + +function validSummary(value: AuctionTraceSummary | undefined): value is AuctionTraceSummary { + return ( + value?.version === 1 && + isCanonicalTraceUuid(value.auctionTraceId) && + TRACE_SOURCES.has(value.source) && + TRACE_OUTCOMES.has(value.outcome) + ); +} + +function validBid(value: AuctionBidData | undefined, slotId: string): boolean { + const trace = value?.trace; + return !!( + trace?.version === 1 && + trace.slotId === slotId && + isCanonicalTraceUuid(trace.auctionTraceId) && + isCanonicalTraceUuid(trace.bidTraceId) && + isBoundedTraceLabel(trace.provider) && + isBoundedTraceLabel(trace.bidder) + ); +} + +function consumeActiveBootstrap(): boolean { + if (window.__tsjs_adTraceActive !== true) return false; + delete window.__tsjs_adTraceActive; + return true; +} + +/** Install the session-scoped recorder, immutable API, and overlay once. */ +export function installAdTrace(): boolean { + if (typeof window === 'undefined') return false; + if (window.tsjs?.adTrace) return true; + if (!consumeActiveBootstrap()) return false; + const ts = (window.tsjs ??= {} as TsjsApi); + + const store = createAdTraceStore(); + const api: AdTraceApi = Object.freeze({ + getSlot: store.getSlot, + getEvents: store.getEvents, + getRenderTimeline: store.getRenderTimeline, + export: store.export, + }); + ts.adTrace = api; + ts.recordAdTrace = store.record; + ts.nextAdTraceGeneration = store.nextGeneration; + ts.subscribeAdTrace = store.subscribe; + ts.bindAdTraceElement = store.bindElement; + ts.getAdTraceElement = store.getBoundElement; + ts.updateAdTraceVisibility = store.updateVisibility; + if (!ts.captureAdTraceRequest) { + ts.captureAdTraceRequest = (slot, trigger, snapshot) => { + const pending = (ts.pendingAdTraceRequests ??= []); + if (pending.length < 64) pending.push({ slot, trigger, snapshot }); + return 0; + }; + } + + const summary = validSummary(ts.auctionTrace) ? ts.auctionTrace : undefined; + for (const slot of ts.adSlots ?? []) { + const bid = ts.bids?.[slot.id]; + if (validBid(bid, slot.id) && bid?.trace) { + store.record({ + kind: 'ts_winner_observed', + slotId: slot.id, + auctionTraceId: bid.trace.auctionTraceId, + bidTraceId: bid.trace.bidTraceId, + provider: bid.trace.provider, + bidder: bid.trace.bidder, + }); + } else if (summary) { + store.record({ + kind: 'ts_auction_observed', + slotId: slot.id, + auctionTraceId: summary.auctionTraceId, + outcome: + summary.outcome === 'completed' || summary.outcome === 'no_bid' + ? 'no_bid' + : summary.outcome === 'skipped' + ? 'skipped' + : 'unresolved', + confidence: 'definitive', + reason: 'terminal_summary', + }); + } + } + + installAdTraceOverlay(api, store.subscribe); + return true; +} + +if (typeof window !== 'undefined') installAdTrace(); diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts new file mode 100644 index 000000000..fd08963d0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts @@ -0,0 +1,231 @@ +import type { + AdTraceApi, + RenderTraceSnapshot, + RenderTraceVisibility, + SlotTraceSnapshot, +} from '../../core/types'; + +const HOST_ID = 'ts-ad-trace-overlay'; +const TRACE_ATTRIBUTES = [ + 'data-ts-trace-seq', + 'data-ts-trace-generation', + 'data-ts-auction-trace-id', + 'data-ts-bid-trace-id', + 'data-ts-trace-outcome', + 'data-ts-trace-visibility', +] as const; + +function stageLine(label: string, stage: { outcome: string; confidence: string }): string { + return `${label}: ${stage.outcome} · ${stage.confidence}`; +} + +function badgeText(slot: SlotTraceSnapshot, render?: RenderTraceSnapshot): string { + return [ + render ? `#${render.sequence}: ${render.outcome} · ${render.visibility}` : undefined, + stageLine('TS winner', slot.stages.trustedServer), + stageLine('Prebid winner', slot.stages.prebid), + stageLine('GAM result', slot.stages.gam), + stageLine('Creative', slot.stages.creative), + ] + .filter(Boolean) + .join('\n'); +} + +function removeTraceAttributes(element: HTMLElement): void { + for (const attribute of TRACE_ATTRIBUTES) element.removeAttribute(attribute); +} + +function effectiveVisibility(element: HTMLElement, rect: DOMRect): RenderTraceVisibility { + if (!element.isConnected) return 'disconnected'; + if (rect.width <= 0 || rect.height <= 0) return 'hidden'; + let current: HTMLElement | null = element; + while (current) { + const style = getComputedStyle(current); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return 'hidden'; + } + current = current.parentElement; + } + return 'visible'; +} + +function stampRender(element: HTMLElement, render: RenderTraceSnapshot): void { + removeTraceAttributes(element); + element.setAttribute('data-ts-trace-seq', String(render.sequence)); + element.setAttribute('data-ts-trace-generation', String(render.generation)); + element.setAttribute('data-ts-trace-outcome', render.outcome); + element.setAttribute('data-ts-trace-visibility', render.visibility); + if (render.auctionTraceId) + element.setAttribute('data-ts-auction-trace-id', render.auctionTraceId); + if (render.bidTraceId) element.setAttribute('data-ts-bid-trace-id', render.bidTraceId); +} + +/** Install one read-only Shadow DOM trace console. */ +export function installAdTraceOverlay( + api: AdTraceApi, + subscribe: (fn: () => void) => () => void +): void { + if (document.getElementById(HOST_ID)) return; + const host = document.createElement('div'); + host.id = HOST_ID; + const root = host.attachShadow({ mode: 'closed' }); + const style = document.createElement('style'); + style.textContent = ` + :host { all: initial; } + .badge { position: fixed; z-index: 2147483647; max-width: 300px; padding: 6px 8px; + border: 1px solid #72e0a6; border-radius: 4px; background: rgba(10,18,16,.94); + color: #eefbf4; font: 11px/1.35 ui-monospace, monospace; white-space: pre; cursor: pointer; } + .badge.probable { border-color: #67a8ff; } + .panel { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: 460px; + max-height: 60vh; overflow: auto; padding: 10px; background: #0a1210; color: #eefbf4; + border: 1px solid #72e0a6; font: 11px/1.4 ui-monospace, monospace; } + .controls { display: flex; gap: 6px; position: sticky; top: 0; background: #0a1210; } + .warning { color: #ffd479; margin: 6px 0; } + .row { border-top: 1px solid #29443a; padding: 6px 0; } + .row strong { color: #72e0a6; } + button { margin-bottom: 6px; } pre { white-space: pre-wrap; }`; + root.appendChild(style); + const badgeLayer = document.createElement('div'); + const panel = document.createElement('div'); + panel.className = 'panel'; + const controls = document.createElement('div'); + controls.className = 'controls'; + const collapseButton = document.createElement('button'); + collapseButton.textContent = 'Collapse'; + const exportButton = document.createElement('button'); + exportButton.textContent = 'Export trace'; + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close'; + const warning = document.createElement('div'); + warning.className = 'warning'; + warning.textContent = 'A non-empty GAM response alone is not proof of a Trusted Server creative.'; + const rows = document.createElement('div'); + const details = document.createElement('pre'); + details.hidden = true; + controls.append(collapseButton, exportButton, closeButton); + panel.append(controls, warning, rows, details); + root.append(badgeLayer, panel); + document.documentElement.appendChild(host); + let cleanup = (): void => {}; + + exportButton.addEventListener('click', () => { + const blob = new Blob([JSON.stringify(api.export(), null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'trusted-server-ad-trace.json'; + link.click(); + URL.revokeObjectURL(url); + }); + collapseButton.addEventListener('click', () => { + rows.hidden = !rows.hidden; + warning.hidden = rows.hidden; + collapseButton.textContent = rows.hidden ? 'Expand' : 'Collapse'; + }); + closeButton.addEventListener('click', () => { + cleanup(); + host.remove(); + }); + + let observedElements = new Set(); + const resizeObserver = + typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => schedule()); + + const render = (): void => { + badgeLayer.replaceChildren(); + rows.replaceChildren(); + const exported = api.export(); + const slotById = new Map(exported.slots.map((slot) => [slot.slotId, slot])); + const latestBySlot = new Map(); + for (const item of exported.renders) latestBySlot.set(item.slotId, item); + const nextObserved = new Set(); + + for (const item of [...exported.renders].reverse()) { + const row = document.createElement('div'); + row.className = 'row'; + const title = document.createElement('strong'); + title.textContent = `#${item.sequence} ${item.slotId} · ${item.source}`; + const summary = document.createElement('div'); + summary.textContent = `${item.outcome} · ${item.confidence} · ${item.visibility}`; + row.append(title, summary); + row.addEventListener('click', () => { + details.hidden = false; + details.textContent = JSON.stringify( + { render: item, stages: slotById.get(item.slotId)?.stages }, + null, + 2 + ); + }); + rows.appendChild(row); + } + + for (const [slotId, slot] of slotById) { + const item = latestBySlot.get(slotId); + const element = item ? window.tsjs?.getAdTraceElement?.(slotId, item.generation) : undefined; + if (!element || !item) continue; + const rect = element.getBoundingClientRect(); + const visibility = effectiveVisibility(element, rect); + window.tsjs?.updateAdTraceVisibility?.(slotId, item.generation, visibility); + const effectiveItem = visibility === item.visibility ? item : { ...item, visibility }; + if (visibility === 'disconnected') { + resizeObserver?.unobserve(element); + removeTraceAttributes(element); + continue; + } + nextObserved.add(element); + if (!observedElements.has(element)) resizeObserver?.observe(element); + stampRender(element, effectiveItem); + const badge = document.createElement('div'); + badge.className = `badge ${item.outcome === 'confirmed' ? '' : 'probable'}`; + badge.textContent = badgeText(slot, effectiveItem); + badge.style.left = `${Math.max(0, rect.left)}px`; + badge.style.top = `${Math.max(0, rect.top)}px`; + badge.addEventListener('click', () => { + panel.hidden = false; + details.hidden = false; + details.textContent = JSON.stringify( + { render: effectiveItem, stages: slot.stages }, + null, + 2 + ); + }); + badgeLayer.appendChild(badge); + } + for (const element of observedElements) { + if (!nextObserved.has(element)) { + resizeObserver?.unobserve(element); + removeTraceAttributes(element); + } + } + observedElements = nextObserved; + }; + + let framePending = false; + const schedule = (): void => { + if (framePending) return; + framePending = true; + requestAnimationFrame(() => { + framePending = false; + if (host.isConnected) render(); + }); + }; + const unsubscribe = subscribe(schedule); + let cleaned = false; + cleanup = (): void => { + if (cleaned) return; + cleaned = true; + unsubscribe(); + resizeObserver?.disconnect(); + for (const element of observedElements) removeTraceAttributes(element); + window.removeEventListener('scroll', schedule); + window.removeEventListener('resize', schedule); + lifecycleObserver.disconnect(); + }; + const lifecycleObserver = new MutationObserver(() => { + if (!host.isConnected) cleanup(); + }); + lifecycleObserver.observe(document.documentElement, { childList: true, subtree: true }); + window.addEventListener('scroll', schedule, { passive: true }); + window.addEventListener('resize', schedule, { passive: true }); + render(); +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 8c0dcacba..8ab89ed6e 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import type { AuctionSlot, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; import { APS_UNIVERSAL_CREATIVE_RENDERER, APS_UNIVERSAL_CREATIVE_RENDERER_VERSION, @@ -40,7 +40,11 @@ const TS_BID_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; -const TS_BASE_TARGETING_KEYS = [...TS_BID_TARGETING_KEYS, TS_INITIAL_TARGETING_KEY] as const; +const TS_BASE_TARGETING_KEYS = [ + ...TS_BID_TARGETING_KEYS, + TS_INITIAL_TARGETING_KEY, + 'ts_trace', +] as const; // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) @@ -56,8 +60,189 @@ interface GoogleTagSlot { } interface SlotRenderEndedEvent { - isEmpty: boolean; + isEmpty?: boolean; + isBackfill?: boolean; + slot: GoogleTagSlot; +} + +interface GptSlotEvent { slot: GoogleTagSlot; + isEmpty?: boolean; + isBackfill?: boolean; +} + +interface RenderCandidate { + slotId: string; + generation: number; + slot: GoogleTagSlot; + divId: string; + /** Renderable only when this record's own hb_adid matches the request snapshot. */ + bid?: Readonly; + adId?: string; + traceToken?: string; + createdAt: number; + terminal: boolean; + consumed: boolean; + superseded: boolean; +} + +interface ExpectedRender { + candidate: RenderCandidate; + source: MessageEventSource; + expiresAt: number; + consumed: boolean; +} + +interface AdTraceRequestBoundarySnapshot { + slotId?: string; + bidder?: string; + adId?: string; + traceToken?: string; + bid?: AuctionBidData; +} + +const requestCandidates = new Map(); +const expectedRenders = new Map(); +const fallbackGenerations = new Map(); + +const MAX_EXPECTED_RENDERS = 200; +const MAX_FALLBACK_GENERATIONS = 200; +const MAX_ACTIVE_CACHE_RENDERS = 64; +const MAX_PRIVATE_REQUEST_OWNERS = 64; +let privateNavigationGeneration = 0; + +interface PrivateRequestOwner { + slotId: string; + adId?: string; + bid?: Readonly; + generation?: number; + element: HTMLElement | null; + navigationGeneration: number; + expiresAt: number; + served: boolean; +} + +const latestPrivateRequestBySlot = new Map(); +const staleTsAdIdBits = new Uint32Array(64); + +function staleAdIdHashes(value: string): [number, number] { + let first = 2166136261; + let second = 5381; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + first = Math.imul(first ^ code, 16777619) >>> 0; + second = (Math.imul(second, 33) ^ code) >>> 0; + } + return [first % 2048, second % 2048]; +} + +function rememberStaleAdIdBits(adId: string): void { + for (const hash of staleAdIdHashes(adId)) { + staleTsAdIdBits[hash >>> 5] |= 1 << (hash & 31); + } +} + +function staleAdIdBitsContain(adId: string): boolean { + return staleAdIdHashes(adId).every( + (hash) => (staleTsAdIdBits[hash >>> 5] & (1 << (hash & 31))) !== 0 + ); +} + +interface ActiveCacheRender { + controller: AbortController; + slotId: string; + adId: string; + source: MessageEventSource | null; + generation?: number; + candidate?: RenderCandidate; + cacheHost: string; + cachePath: string; + traceToken?: string; + navigationGeneration: number; + expiresAt: number; + expiryTimer?: ReturnType; +} + +const activeCacheRenders = new Set(); +const latestCacheRenderBySlot = new Map(); + +function rememberStaleTsOwner(owner: PrivateRequestOwner): void { + if (!owner.bid || !owner.adId) return; + rememberStaleAdIdBits(owner.adId); +} + +function retireActiveCacheRender(render: ActiveCacheRender): void { + if (render.expiryTimer) clearTimeout(render.expiryTimer); + render.controller.abort(); + activeCacheRenders.delete(render); + if (latestCacheRenderBySlot.get(render.slotId) === render) { + latestCacheRenderBySlot.delete(render.slotId); + } +} + +function invalidatePrivateRequestOwners(slotId?: string): void { + const entries = slotId + ? [[slotId, latestPrivateRequestBySlot.get(slotId)] as const] + : [...latestPrivateRequestBySlot.entries()]; + for (const [key, owner] of entries) { + if (!owner) continue; + rememberStaleTsOwner(owner); + latestPrivateRequestBySlot.delete(key); + } +} + +function abortActiveCacheRenders(slotId?: string): void { + privateNavigationGeneration += slotId ? 0 : 1; + for (const render of [...activeCacheRenders]) { + if (!slotId || render.slotId === slotId) retireActiveCacheRender(render); + } + invalidatePrivateRequestOwners(slotId); +} + +function claimPrivateRequestOwner( + slotId: string, + adId: string | undefined, + bid: Readonly | undefined, + element: HTMLElement | null +): PrivateRequestOwner { + for (const render of [...activeCacheRenders]) { + if (render.slotId === slotId) retireActiveCacheRender(render); + } + const previous = latestPrivateRequestBySlot.get(slotId); + if (previous) rememberStaleTsOwner(previous); + const owner: PrivateRequestOwner = { + slotId, + adId, + bid, + element, + navigationGeneration: privateNavigationGeneration, + expiresAt: monotonicNow() + 30_000, + served: false, + }; + latestPrivateRequestBySlot.delete(slotId); + latestPrivateRequestBySlot.set(slotId, owner); + while (latestPrivateRequestBySlot.size > MAX_PRIVATE_REQUEST_OWNERS) { + const oldest = latestPrivateRequestBySlot.keys().next().value as string | undefined; + if (!oldest) break; + const evicted = latestPrivateRequestBySlot.get(oldest); + if (evicted) rememberStaleTsOwner(evicted); + latestPrivateRequestBySlot.delete(oldest); + for (const render of [...activeCacheRenders]) { + if (render.slotId === oldest) retireActiveCacheRender(render); + } + } + return owner; +} + +function isKnownStaleTsAdId(adId: string): boolean { + // The fixed-size bitset intentionally never forgets within the page session: + // false positives fail closed, while bounded-map eviction cannot create a + // false negative that lets a stale TS Universal Creative fall through. + return staleAdIdBitsContain(adId); +} + +function monotonicNow(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); } function findSlotElementByDivId(divId: string): HTMLElement | null { @@ -126,7 +311,7 @@ interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; - addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; + addEventListener(event: string, fn: (e: GptSlotEvent) => void): void; refresh(slots?: GoogleTagSlot[]): void; getSlots?(): GoogleTagSlot[]; disableInitialLoad?(): void; @@ -156,6 +341,39 @@ type GptWindow = Window & { __tsjs_slim_prebid_url?: string; }; +const cacheInvalidationHookedTags = new WeakSet(); +const cacheInvalidationHookedSlots = new WeakSet(); + +function installSlotCacheInvalidationHook(slot: GoogleTagSlot): void { + if (cacheInvalidationHookedSlots.has(slot) || typeof slot.clearTargeting !== 'function') return; + const original = slot.clearTargeting.bind(slot); + slot.clearTargeting = (key?: string) => { + const slotId = slotIdForGptSlot(slot); + if (slotId) abortActiveCacheRenders(slotId); + return original(key); + }; + cacheInvalidationHookedSlots.add(slot); +} + +function installGoogleTagCacheInvalidationHooks(g: Partial): void { + if (cacheInvalidationHookedTags.has(g)) return; + if (typeof g.destroySlots === 'function') { + const original = g.destroySlots.bind(g); + g.destroySlots = (slots?: GoogleTagSlot[]) => { + if (slots) { + slots.forEach((slot) => { + const slotId = slotIdForGptSlot(slot); + if (slotId) abortActiveCacheRenders(slotId); + }); + } else { + abortActiveCacheRenders(); + } + return original(slots); + }; + } + cacheInvalidationHookedTags.add(g); +} + // ------------------------------------------------------------------ // Shim implementation // ------------------------------------------------------------------ @@ -377,24 +595,40 @@ function injectAdmIntoSlot(divId: string, adm: string): void { } } -function fireWinBillingBeacons(slotId: string, bid: AuctionBidData): void { - if (!slotId || (!bid.nurl && !bid.burl)) return; +const MAX_BILLING_DEDUPE_KEYS = 512; +const BILLING_DEDUPE_TTL_MS = 30 * 60_000; +const firedBillingKeys = new Map(); - const fired = (window.tsjs!.firedBeacons ??= {}); +function billingEntries(slotId: string, bid: AuctionBidData): Array<[string, string]> { const bidIdentity = bid.hb_adid ?? bid.nurl ?? bid.burl ?? ''; - const urls = [ - ['nurl', bid.nurl], - ['burl', bid.burl], - ] as const; - - for (const [kind, url] of urls) { - if (!url) continue; + return ( + [ + ['nurl', bid.nurl], + ['burl', bid.burl], + ] as const + ).flatMap(([kind, url]) => + url ? [[`${slotId}|${bidIdentity}|${kind}|${url}`, url] as [string, string]] : [] + ); +} - const beaconKey = `${slotId}|${bidIdentity}|${kind}|${url}`; - if (fired[beaconKey]) continue; +function billingCapacityAvailable(slotId: string, bid: AuctionBidData): boolean { + const now = monotonicNow(); + for (const [key, expiresAt] of firedBillingKeys) { + if (expiresAt <= now) firedBillingKeys.delete(key); + } + const additional = billingEntries(slotId, bid).filter( + ([key]) => !firedBillingKeys.has(key) + ).length; + return firedBillingKeys.size + additional <= MAX_BILLING_DEDUPE_KEYS; +} +function fireWinBillingBeacons(slotId: string, bid: AuctionBidData): void { + if (!slotId) return; + const now = monotonicNow(); + for (const [key, url] of billingEntries(slotId, bid)) { + if (firedBillingKeys.has(key)) continue; if (queueWinBillingBeacon(url)) { - fired[beaconKey] = true; + firedBillingKeys.set(key, now + BILLING_DEDUPE_TTL_MS); } } } @@ -494,8 +728,363 @@ function installInitialLoadDetector(ts: TsjsApi): void { }); } +function slotIdForGptSlot(slot: GoogleTagSlot): string | undefined { + const divId = slot.getSlotElementId?.() ?? ''; + return ( + window.tsjs?.divToSlotId?.[divId] ?? + window.tsjs?.adSlots?.find((item) => { + return ( + divId === item.div_id || + divId === `${item.div_id}-container` || + divId.startsWith(item.div_id) + ); + })?.id + ); +} + +function firstSlotTarget(slot: GoogleTagSlot, key: string): string | undefined { + return slot.getTargeting?.(key)?.find((value) => value.length > 0); +} + +function supersedeCandidate(candidate: RenderCandidate, reason: string): void { + if (candidate.superseded) return; + candidate.superseded = true; + for (const render of [...activeCacheRenders]) { + if (render.candidate === candidate) retireActiveCacheRender(render); + } + window.tsjs?.recordAdTrace?.({ + kind: 'generation_superseded', + slotId: candidate.slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + reason, + }); +} + +export function supersedeAdTraceSlot(slot: GoogleTagSlot, reason: string): void { + const slotId = slotIdForGptSlot(slot); + if (slotId) { + abortActiveCacheRenders(slotId); + if (window.tsjs?.prebidSelectedParticipants) { + window.tsjs.prebidSelectedParticipants = window.tsjs.prebidSelectedParticipants.filter( + (entry) => entry.slotId !== slotId + ); + } + } + for (const candidates of requestCandidates.values()) { + candidates + .filter((candidate) => candidate.slot === slot && !candidate.superseded) + .forEach((candidate) => supersedeCandidate(candidate, reason)); + } +} + +/** Capture immutable attribution immediately before one concrete GPT request. */ +export function captureAdTraceRequest( + slot: GoogleTagSlot, + trigger: string, + snapshot?: AdTraceRequestBoundarySnapshot +): number { + const ts = window.tsjs; + const hasBoundarySnapshot = snapshot !== undefined; + const slotId = hasBoundarySnapshot ? snapshot.slotId : slotIdForGptSlot(slot); + if (!slotId) return 0; + installSlotCacheInvalidationHook(slot); + + // Private service ownership is captured for every GPT request, even when the + // diagnostic recorder is disabled. It must precede all asynchronous render + // work so a later request or navigation can invalidate the exact owner. + const bidder = hasBoundarySnapshot ? snapshot.bidder : firstSlotTarget(slot, 'hb_bidder'); + const adId = hasBoundarySnapshot ? snapshot.adId : firstSlotTarget(slot, 'hb_adid'); + const rawTraceToken = hasBoundarySnapshot + ? snapshot.traceToken + : firstSlotTarget(slot, 'ts_trace'); + const traceToken = + rawTraceToken && TRACE_TOKEN_RE.test(rawTraceToken) ? rawTraceToken : undefined; + const liveBid = hasBoundarySnapshot ? snapshot.bid : ts?.bids?.[slotId]; + const renderBidMatches = + !!liveBid && + !!adId && + liveBid.hb_adid === adId && + (!traceToken || liveBid.trace?.bidTraceId === traceToken); + const divId = slot.getSlotElementId?.() ?? ''; + const privateBid = renderBidMatches ? Object.freeze({ ...liveBid }) : undefined; + const privateOwner = claimPrivateRequestOwner( + slotId, + adId, + privateBid, + divId ? findSlotElementByDivId(divId) : null + ); + + if (!ts?.recordAdTrace) return 0; + (requestCandidates.get(slotId) ?? []) + .filter((candidate) => !candidate.superseded && !candidate.consumed) + .forEach((candidate) => supersedeCandidate(candidate, 'request_replaced')); + const generation = + ts.nextAdTraceGeneration?.(slotId) ?? (fallbackGenerations.get(slotId) ?? 0) + 1; + privateOwner.generation = generation; + fallbackGenerations.delete(slotId); + fallbackGenerations.set(slotId, generation); + while (fallbackGenerations.size > MAX_FALLBACK_GENERATIONS) { + const oldest = fallbackGenerations.keys().next().value as string | undefined; + if (!oldest) break; + fallbackGenerations.delete(oldest); + } + + // Diagnostic attribution reads the same immutable request-boundary values as + // the private owner, but remains optional and independently gated. + const ledger = ts.prebidCorrelation ?? []; + const selectedMatches = traceToken + ? ledger.filter((entry) => entry.slotId === slotId && entry.traceToken === traceToken) + : adId + ? ledger.filter((entry) => entry.slotId === slotId && entry.adId === adId) + : []; + const selectedParticipant = selectedMatches.length === 1 ? selectedMatches[0] : undefined; + const completedAuction = [...(ts.prebidCompletedAuctions ?? [])] + .reverse() + .find((entry) => entry.slotIds.includes(slotId)); + const auctionId = + selectedParticipant?.auctionId ?? (!adId ? completedAuction?.auctionId : undefined); + const participants = auctionId + ? ledger.filter((entry) => entry.slotId === slotId && entry.auctionId === auctionId) + : []; + const hasTracedTsParticipant = participants.some((entry) => !!entry.traceToken); + const tracedServerParticipant = participants.find((entry) => entry.serverTrace); + const serverSummary = auctionId + ? (ts.prebidServerSummaries ?? []).find( + (entry) => entry.auctionId === auctionId && entry.slotId === slotId + )?.summary + : undefined; + if (selectedParticipant) { + const selected = (ts.prebidSelectedParticipants ??= []).filter( + (entry) => monotonicNow() - entry.selectedAt <= 30_000 + ); + selected.push({ + auctionId: selectedParticipant.auctionId, + slotId, + requestId: selectedParticipant.requestId, + adId: selectedParticipant.adId, + traceToken: selectedParticipant.traceToken, + bidder: selectedParticipant.bidder, + generation, + selectedAt: monotonicNow(), + }); + while (selected.length > 128) selected.shift(); + ts.prebidSelectedParticipants = selected; + } + if (auctionId) { + ts.prebidCorrelation = ledger.filter( + (entry) => !(entry.slotId === slotId && entry.auctionId === auctionId) + ); + ts.prebidCompletedAuctions = (ts.prebidCompletedAuctions ?? []).filter( + (entry) => entry.auctionId !== auctionId + ); + ts.prebidServerSummaries = (ts.prebidServerSummaries ?? []).filter( + (entry) => !(entry.auctionId === auctionId && entry.slotId === slotId) + ); + } + + const candidate: RenderCandidate = { + slotId, + generation, + slot, + divId, + ...(privateBid ? { bid: privateBid } : {}), + adId, + traceToken, + createdAt: monotonicNow(), + terminal: false, + consumed: false, + superseded: false, + }; + const capturedElement = candidate.divId ? findSlotElementByDivId(candidate.divId) : null; + if (capturedElement) ts.bindAdTraceElement?.(slotId, generation, capturedElement); + if (!requestCandidates.has(slotId) && requestCandidates.size >= 64) { + const oldestSlotId = requestCandidates.keys().next().value as string | undefined; + if (oldestSlotId) { + requestCandidates + .get(oldestSlotId) + ?.forEach((item) => supersedeCandidate(item, 'slot_evicted')); + requestCandidates.delete(oldestSlotId); + } + } + const candidates = requestCandidates.get(slotId) ?? []; + candidates + .filter((item) => !item.superseded && monotonicNow() - item.createdAt > 30_000) + .forEach((item) => supersedeCandidate(item, 'generation_expired')); + candidates.push(candidate); + if (candidates.length > 8) { + const evicted = candidates.shift(); + if (evicted) supersedeCandidate(evicted, 'generation_evicted'); + } + requestCandidates.set(slotId, candidates); + + const serverTrace = tracedServerParticipant?.serverTrace; + if (serverTrace) { + ts.recordAdTrace({ + kind: 'ts_winner_observed', + slotId, + generation, + auctionTraceId: serverTrace.auctionTraceId, + bidTraceId: serverTrace.bidTraceId, + provider: serverTrace.provider, + bidder: serverTrace.bidder, + }); + } else if (serverSummary) { + ts.recordAdTrace({ + kind: 'ts_auction_observed', + slotId, + generation, + auctionTraceId: serverSummary.auctionTraceId, + outcome: serverSummary.outcome === 'completed' ? 'no_bid' : serverSummary.outcome, + confidence: 'definitive', + reason: 'terminal_summary', + }); + } + + let outcome = 'no_bid'; + let reason = 'no_selected_targeting'; + let confidence: 'definitive' | 'none' = 'definitive'; + if (selectedMatches.length > 1) { + outcome = 'unresolved'; + reason = 'ambiguous_prebid_request'; + confidence = 'none'; + } else if (selectedParticipant) { + if (traceToken && selectedParticipant.traceToken === traceToken) outcome = 'won'; + else if (!traceToken) outcome = hasTracedTsParticipant ? 'lost' : 'client_bid_won'; + else outcome = hasTracedTsParticipant ? 'lost' : 'unresolved'; + reason = 'selected_targeting'; + } else if (completedAuction && !bidder && !adId && !traceToken) { + outcome = 'no_bid'; + reason = 'prebid_no_bid'; + } else if (bidder || adId || traceToken) { + outcome = traceToken && renderBidMatches ? 'not_run' : 'client_bid_won'; + reason = traceToken && renderBidMatches ? 'direct_gpt_request' : 'unjoined_targeting'; + if (!traceToken && !renderBidMatches) confidence = 'none'; + } + ts.recordAdTrace({ + kind: 'prebid_targeting_selected', + slotId, + generation, + bidTraceId: traceToken, + bidder, + outcome, + confidence, + reason, + }); + for (const kind of selectedParticipant?.events ?? []) { + ts.recordAdTrace({ + kind, + slotId, + generation, + bidTraceId: traceToken, + bidder, + }); + } + if (liveBid?.hb_bidder === 'aps' || liveBid?.hb_bidder === 'amazon-aps') { + ts.recordAdTrace({ + kind: 'aps_display_bids_set', + slotId, + generation, + bidTraceId: traceToken, + }); + } + ts.recordAdTrace({ + kind: 'gpt_request_started', + slotId, + generation, + auctionTraceId: liveBid?.trace?.auctionTraceId ?? ts.auctionTrace?.auctionTraceId, + bidTraceId: traceToken, + provider: liveBid?.trace?.provider, + bidder, + reason: trigger, + }); + return generation; +} + +function candidateForSlot( + slot: GoogleTagSlot, + includeTerminal = false +): RenderCandidate | undefined { + const slotId = slotIdForGptSlot(slot); + if (!slotId) return undefined; + const candidates = (requestCandidates.get(slotId) ?? []).filter( + (candidate) => + candidate.slot === slot && + !candidate.superseded && + (includeTerminal || !candidate.terminal) && + monotonicNow() - candidate.createdAt <= 30_000 + ); + if (candidates.length !== 1) { + if (candidates.length > 1) { + candidates.forEach((candidate) => + window.tsjs?.recordAdTrace?.({ + kind: 'gpt_slot_render_ended', + slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + outcome: 'unresolved', + confidence: 'none', + reason: 'overlapping_request', + }) + ); + } else { + window.tsjs?.recordAdTrace?.({ + kind: 'gpt_slot_response_received', + slotId, + outcome: 'unresolved', + confidence: 'none', + reason: 'missing_generation', + }); + } + return undefined; + } + return candidates[0]; +} + +function installGptEvidenceListeners(service: GoogleTagPubAdsService): void { + if (!window.tsjs?.recordAdTrace) return; + const instrumented = service as GoogleTagPubAdsService & { __tsAdTraceListeners?: boolean }; + if (instrumented.__tsAdTraceListeners) return; + instrumented.__tsAdTraceListeners = true; + const record = + (kind: 'gpt_slot_requested' | 'gpt_slot_response_received' | 'gpt_slot_onload') => + (event: GptSlotEvent): void => { + const candidate = candidateForSlot(event.slot, kind === 'gpt_slot_onload'); + if (!candidate) return; + window.tsjs?.recordAdTrace?.({ + kind, + slotId: candidate.slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + }); + }; + service.addEventListener('slotRequested', record('gpt_slot_requested')); + service.addEventListener('slotResponseReceived', record('gpt_slot_response_received')); + service.addEventListener('slotOnload', record('gpt_slot_onload')); + service.addEventListener('slotRenderEnded', (event: GptSlotEvent) => { + const candidate = candidateForSlot(event.slot); + if (!candidate) return; + candidate.terminal = true; + window.tsjs?.recordAdTrace?.({ + kind: 'gpt_slot_render_ended', + slotId: candidate.slotId, + generation: candidate.generation, + bidTraceId: candidate.traceToken, + isEmpty: event.isEmpty, + isBackfill: event.isBackfill, + }); + }); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); + const pendingBootstrapRequests = ts.pendingAdTraceRequests ?? []; + ts.pendingAdTraceRequests = []; + ts.captureAdTraceRequest = (slot, trigger, snapshot) => + captureAdTraceRequest(slot as GoogleTagSlot, trigger, snapshot); + pendingBootstrapRequests.forEach(({ slot, trigger, snapshot }) => + ts.captureAdTraceRequest?.(slot, trigger, snapshot) + ); installInitialLoadDetector(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; @@ -503,18 +1092,51 @@ export function installTsAdInit(): void { // The slotRenderEnded listener below reads ts.bids live so SPA navigation // updates (new ts.bids injected before ) are picked up at render time. const bids = ts.bids ?? {}; + const summary = ts.auctionTrace; + for (const slot of slots) { + const bid = bids[slot.id]; + if (bid?.trace && TRACE_TOKEN_RE.test(bid.trace.bidTraceId)) { + ts.recordAdTrace?.({ + kind: 'ts_winner_observed', + slotId: slot.id, + auctionTraceId: bid.trace.auctionTraceId, + bidTraceId: bid.trace.bidTraceId, + provider: bid.trace.provider, + bidder: bid.trace.bidder, + }); + } else if (summary) { + ts.recordAdTrace?.({ + kind: 'ts_auction_observed', + slotId: slot.id, + auctionTraceId: summary.auctionTraceId, + outcome: + summary.outcome === 'completed' || summary.outcome === 'no_bid' + ? 'no_bid' + : summary.outcome === 'skipped' + ? 'skipped' + : 'unresolved', + confidence: 'definitive', + reason: 'terminal_summary', + }); + } + } const g = (window as GptWindow).googletag; if (!g) return; g.cmd?.push(() => { + installGoogleTagCacheInvalidationHooks(g); // Destroy previously defined TS slots before redefining for the new page. if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + (ts.prevGptSlots as GoogleTagSlot[]).forEach((slot) => + supersedeAdTraceSlot(slot, 'slot_destroyed') + ); g.destroySlots?.(ts.prevGptSlots as GoogleTagSlot[]); ts.prevGptSlots = []; } // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. + installGptEvidenceListeners(g.pubads!()); const newSlots: GoogleTagSlot[] = []; // Publisher-owned slots TS reused — refreshed to pick up server-side // targeting. The publisher already display()ed these. @@ -542,6 +1164,7 @@ export function installTsAdInit(): void { (g.pubads!().getSlots?.() ?? []).forEach((gptSlot: GoogleTagSlot) => { const elementId = gptSlot.getSlotElementId(); if (!prevTouchedDivIds.has(elementId)) return; + supersedeAdTraceSlot(gptSlot, 'targeting_cleared'); clearTargetingKeys(gptSlot, [ ...TS_BASE_TARGETING_KEYS, ...(prevSlotTargetingKeys[elementId] ?? []), @@ -578,6 +1201,7 @@ export function installTsAdInit(): void { tsOwned = true; } + installSlotCacheInvalidationHook(gptSlot); const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; clearTargetingKeys(gptSlot, [ ...TS_BASE_TARGETING_KEYS, @@ -589,7 +1213,18 @@ export function installTsAdInit(): void { TS_BID_TARGETING_KEYS.forEach((key) => { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); + if (bid.trace?.bidTraceId && TRACE_TOKEN_RE.test(bid.trace.bidTraceId)) { + gptSlot.setTargeting('ts_trace', bid.trace.bidTraceId); + } gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + ts.recordAdTrace?.({ + kind: 'gpt_targeting_applied', + slotId: slot.id, + auctionTraceId: bid.trace?.auctionTraceId, + bidTraceId: bid.trace?.bidTraceId, + provider: bid.trace?.provider, + bidder: bid.trace?.bidder, + }); // Map both inner div and container div → slot ID so slotRenderEnded // (which reports the GPT slot's div, i.e. slotDivId/container) can look up // the slot, while adm injection (which targets the inner div) also works. @@ -605,8 +1240,20 @@ export function installTsAdInit(): void { slotsToRefresh.push(gptSlot); } - // Trusted Server APS winners carry their own typed renderer and never - // enter the publisher-owned native apstag rendering path. + // Typed Trusted Server APS winners render through their own descriptor. + // Only publisher-native APS bids should enter the apstag handoff. + if ( + bid.renderer === undefined && + (bid.hb_bidder === 'aps' || bid.hb_bidder === 'amazon-aps') + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).apstag?.setDisplayBids?.(); + ts.recordAdTrace?.({ + kind: 'aps_display_bids_set', + slotId: slot.id, + bidTraceId: bid.trace?.bidTraceId, + }); + } }); ts.prevGptSlots = newSlots as unknown[]; @@ -654,7 +1301,11 @@ export function installTsAdInit(): void { // called without a matching display call") and misses its impression. // Must run after enableServices(); on SPA navigation services are already // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => g.display?.(divId)); + slotsToDisplay.forEach((divId) => { + const gptSlot = newSlots.find((slot) => slot.getSlotElementId() === divId); + if (gptSlot && !ts.gptInitialLoadDisabled) captureAdTraceRequest(gptSlot, 'display'); + g.display?.(divId); + }); // Slots needing an explicit ad request via refresh(). Reused // publisher-owned slots always need one to pick up the just-applied @@ -677,6 +1328,7 @@ export function installTsAdInit(): void { // the same slots still go through the wrapper normally. ts.adInitRefreshInProgress = true; try { + slotsNeedingRefresh.forEach((slot) => captureAdTraceRequest(slot, 'refresh')); g.pubads!().refresh(slotsNeedingRefresh); } finally { ts.adInitRefreshInProgress = false; @@ -687,6 +1339,7 @@ export function installTsAdInit(): void { } interface PageBidsResponse { + auctionTrace?: AuctionTraceSummary; slots: AuctionSlot[]; bids: Record; } @@ -767,7 +1420,11 @@ export function installSpaAuctionHook(): void { let lastAppliedPath = `${location.pathname}${location.search}`; async function onNavigate(path: string): Promise { + // Navigation invalidates private render ownership even when the resulting + // route key is unchanged (for example a state-only replaceState call). + abortActiveCacheRenders(); if (path === currentPath) return; + ts.prebidSelectedParticipants = []; currentPath = path; inflight?.abort(); const controller = new AbortController(); @@ -798,6 +1455,7 @@ export function installSpaAuctionHook(): void { await waitForSlotElements(data.slots, controller.signal); if (inflight !== controller) return; ts.adSlots = data.slots; + ts.auctionTrace = data.auctionTrace; ts.bids = data.bids; // This route is now the committed, loaded state — a later failed // navigation rolls back here, and a return trip no-ops correctly. @@ -863,6 +1521,8 @@ export function installSlimPrebidLoader(): void { const TS_DISPLAY_RENDERER = '(function(){window.render=function(d,h,w){' + 'var f=h.mkFrame(w.document,{width:d.width||"100%",height:d.height||"100%"});' + + 'if(typeof d.traceToken==="string"){f.addEventListener("load",function(){' + + 'top.postMessage({type:"ts-creative-load",version:1,traceToken:d.traceToken},"*");},{once:true});}' + 'if(d.adUrl&&!d.ad){f.src=d.adUrl;}else{f.srcdoc=d.ad;}' + 'w.document.body.appendChild(f);};})();'; @@ -939,6 +1599,48 @@ export function parseCachedBid(body: string): CachedBid | undefined { }; } +const TRACE_TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function pruneExpectedRenders(): void { + const now = monotonicNow(); + for (const [token, entries] of expectedRenders) { + const retained = entries.filter((entry) => !entry.consumed && entry.expiresAt >= now); + if (retained.length > 0) expectedRenders.set(token, retained); + else expectedRenders.delete(token); + } +} + +function armExpectedRender( + candidate: RenderCandidate | undefined, + source: MessageEventSource | null +): string | undefined { + if ( + !candidate?.bid || + candidate.superseded || + !candidate.traceToken || + !TRACE_TOKEN_RE.test(candidate.traceToken) || + !source + ) { + return undefined; + } + pruneExpectedRenders(); + const expectedCount = [...expectedRenders.values()].reduce( + (count, entries) => count + entries.length, + 0 + ); + if (expectedCount >= MAX_EXPECTED_RENDERS) return undefined; + candidate.consumed = true; + const entries = expectedRenders.get(candidate.traceToken) ?? []; + entries.push({ + candidate, + source, + expiresAt: monotonicNow() + 30_000, + consumed: false, + }); + expectedRenders.set(candidate.traceToken, entries); + return candidate.traceToken; +} + /** * Install the TS → pbRender bridge. * @@ -996,6 +1698,43 @@ export function installTsRenderBridge(): void { return; } + if (data['type'] === 'ts-creative-load') { + const token = data['traceToken']; + if (data['version'] !== 1 || typeof token !== 'string' || !TRACE_TOKEN_RE.test(token)) return; + const entries = expectedRenders.get(token) ?? []; + entries + .filter((entry) => !entry.consumed && entry.expiresAt < monotonicNow()) + .forEach((entry) => supersedeCandidate(entry.candidate, 'ack_expired')); + const matches = entries.filter( + (entry) => + !entry.consumed && + !entry.candidate.superseded && + entry.expiresAt >= monotonicNow() && + entry.source === e.source && + (requestCandidates.get(entry.candidate.slotId) ?? []).includes(entry.candidate) + ); + if (matches.length !== 1) { + const candidate = entries[0]?.candidate; + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_rejected', + slotId: candidate?.slotId, + generation: candidate?.generation, + bidTraceId: TRACE_TOKEN_RE.test(token) ? token : undefined, + reason: matches.length > 1 ? 'ambiguous_generation' : 'invalid_acknowledgement', + }); + return; + } + const expected = matches[0]; + expected.consumed = true; + window.tsjs?.recordAdTrace?.({ + kind: 'creative_load_acknowledged', + slotId: expected.candidate.slotId, + generation: expected.candidate.generation, + bidTraceId: token, + }); + return; + } + if (data['message'] !== 'Prebid Request') return; const adId = data['adId'] as string | undefined; if (!adId) return; @@ -1070,18 +1809,57 @@ export function installTsRenderBridge(): void { const sourceSlotId = slotIdForMessageSource(e.source); if (!sourceSlotId) return; - // Resolve the bid by the requesting slot, not by the first bid whose hb_adid - // matches. hb_adid is not unique per bid: absent PBS Cache, it falls back to a - // creative id a bidder may reuse across slots. A first-match-by-adId lookup - // would resolve every duplicate to one slot, so all but that slot render blank. - const bids = window.tsjs?.bids ?? {}; - const slotId = sourceSlotId; - const matchedBid = bids[slotId]; + const allCandidates = requestCandidates.get(sourceSlotId) ?? []; + allCandidates + .filter((candidate) => !candidate.superseded && monotonicNow() - candidate.createdAt > 30_000) + .forEach((candidate) => supersedeCandidate(candidate, 'generation_expired')); + const candidates = allCandidates.filter( + (candidate) => + candidate.adId === adId && + !candidate.consumed && + !candidate.superseded && + monotonicNow() - candidate.createdAt <= 30_000 + ); + const exactCandidate = candidates.length === 1 ? candidates[0] : undefined; + window.tsjs?.recordAdTrace?.({ + kind: candidates.length === 1 ? 'pb_render_requested' : 'pb_render_rejected', + slotId: sourceSlotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: + candidates.length === 1 + ? 'exact_generation' + : candidates.length > 1 + ? 'ambiguous_generation' + : 'missing_generation', + }); - // Not a TS bid, or the requesting slot's bid does not own this adId — let - // Prebid.js handle it. The adId guard also prevents an iframe under slot A from - // pulling slot B's creative and firing slot B's win/billing beacons. - if (!matchedBid || matchedBid.hb_adid !== adId) return; + const slotId = sourceSlotId; + const requestOwner = latestPrivateRequestBySlot.get(slotId); + const liveBid = window.tsjs?.bids?.[slotId]; + const ownerCurrent = + !!requestOwner?.bid && + requestOwner.adId === adId && + requestOwner.bid.hb_adid === adId && + requestOwner.navigationGeneration === privateNavigationGeneration && + requestOwner.expiresAt >= monotonicNow() && + !requestOwner.served && + !!requestOwner.element?.isConnected && + findSlotElementByDivId(requestOwner.element.id) === requestOwner.element && + slotIdForMessageSource(e.source) === slotId && + liveBid?.hb_adid === requestOwner.bid.hb_adid && + liveBid.hb_cache_host === requestOwner.bid.hb_cache_host && + liveBid.hb_cache_path === requestOwner.bid.hb_cache_path && + liveBid.trace?.bidTraceId === requestOwner.bid.trace?.bidTraceId; + if (!ownerCurrent || !requestOwner?.bid) { + // A once-TS-owned message must not escape to ordinary Prebid after its + // request owner was replaced or invalidated. + if (isKnownStaleTsAdId(adId) || liveBid?.hb_adid === adId) { + e.stopImmediatePropagation(); + } + return; + } + const matchedBid = requestOwner.bid; const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); // Prefer the winning creative's own dimensions; the first configured slot @@ -1102,6 +1880,7 @@ export function installTsRenderBridge(): void { const rendererKey = `${slotId}|${adId}`; if (renderingKeys.has(rendererKey)) return; renderingKeys.add(rendererKey); + requestOwner.served = true; try { port.postMessage( @@ -1116,8 +1895,16 @@ export function installTsRenderBridge(): void { height: renderer.height, }) ); + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: 'aps_renderer', + }); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' through APS renderer`); } catch (err) { + requestOwner.served = false; renderingKeys.delete(rendererKey); log.warn('[tsjs-gpt] pbRender bridge: APS response failed', err); } @@ -1125,6 +1912,9 @@ export function installTsRenderBridge(): void { } if (matchedBid.adm) { + if (!billingCapacityAvailable(slotId, matchedBid)) return; + const traceToken = armExpectedRender(exactCandidate, e.source); + requestOwner.served = true; e.stopImmediatePropagation(); port.postMessage( JSON.stringify({ @@ -1134,9 +1924,16 @@ export function installTsRenderBridge(): void { renderer: TS_DISPLAY_RENDERER, width, height, + ...(traceToken ? { traceToken } : {}), }) ); fireWinBillingBeacons(slotId, matchedBid); + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: exactCandidate?.generation, + bidTraceId: traceToken, + }); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from inline adm`); return; } @@ -1144,31 +1941,121 @@ export function installTsRenderBridge(): void { // No TS render source — let Prebid.js handle it. if (!matchedBid.hb_cache_host || !matchedBid.hb_cache_path) return; - // TS owns this adId — stop Prebid from also processing it. + const capturedSource = e.source; + const capturedElement = requestOwner.element; + const capturedCacheHost = matchedBid.hb_cache_host; + const capturedCachePath = matchedBid.hb_cache_path; + const capturedTraceToken = matchedBid.trace?.bidTraceId; + + const previousOwner = latestCacheRenderBySlot.get(slotId); + if ( + previousOwner && + previousOwner.adId === adId && + previousOwner.source === capturedSource && + previousOwner.generation === requestOwner.generation && + previousOwner.cacheHost === capturedCacheHost && + previousOwner.cachePath === capturedCachePath && + previousOwner.traceToken === capturedTraceToken && + !previousOwner.controller.signal.aborted && + previousOwner.expiresAt >= monotonicNow() + ) { + // A duplicate message for the exact accepted owner must not start a + // second fetch or escape to the ordinary Prebid renderer. + e.stopImmediatePropagation(); + return; + } + if (previousOwner) retireActiveCacheRender(previousOwner); + + // Capacity overflow must not evict a different live billing owner. Leave + // the message untouched so the ordinary Prebid path can process it. + if (activeCacheRenders.size >= MAX_ACTIVE_CACHE_RENDERS) return; + + const controller = new AbortController(); + const activeRender: ActiveCacheRender = { + controller, + slotId, + adId, + source: capturedSource, + generation: requestOwner.generation, + ...(exactCandidate?.generation === requestOwner.generation + ? { candidate: exactCandidate } + : {}), + cacheHost: capturedCacheHost, + cachePath: capturedCachePath, + traceToken: capturedTraceToken, + navigationGeneration: privateNavigationGeneration, + expiresAt: requestOwner.expiresAt, + }; + + activeCacheRenders.add(activeRender); + latestCacheRenderBySlot.set(slotId, activeRender); + activeRender.expiryTimer = setTimeout( + () => retireActiveCacheRender(activeRender), + Math.max(0, activeRender.expiresAt - monotonicNow()) + ); + // TS owns this accepted render — stop Prebid from also processing it. e.stopImmediatePropagation(); - // Skip a concurrent re-render of the same slot's adId so its win/billing - // beacons fire at most once even before the first cache fetch resolves. - const renderingKey = `${slotId}|${adId}`; - if (renderingKeys.has(renderingKey)) return; - renderingKeys.add(renderingKey); + const stillCurrent = (): boolean => { + const liveBid = window.tsjs?.bids?.[slotId]; + const candidateCurrent = + !exactCandidate || + (!exactCandidate.superseded && + (requestCandidates.get(slotId) ?? []).includes(exactCandidate)); + return ( + !controller.signal.aborted && + latestCacheRenderBySlot.get(slotId) === activeRender && + latestPrivateRequestBySlot.get(slotId) === requestOwner && + requestOwner.navigationGeneration === privateNavigationGeneration && + requestOwner.expiresAt >= monotonicNow() && + !requestOwner.served && + activeRender.navigationGeneration === privateNavigationGeneration && + activeRender.expiresAt >= monotonicNow() && + candidateCurrent && + !!capturedElement?.isConnected && + findSlotElementByDivId(capturedElement.id) === capturedElement && + slotIdForMessageSource(capturedSource) === slotId && + liveBid?.hb_adid === adId && + liveBid.hb_cache_host === capturedCacheHost && + liveBid.hb_cache_path === capturedCachePath && + liveBid.trace?.bidTraceId === capturedTraceToken + ); + }; - const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; + const cacheUrl = `https://${capturedCacheHost}${capturedCachePath}?uuid=${encodeURIComponent(adId)}`; - fetch(cacheUrl, { mode: 'cors' }) + fetch(cacheUrl, { mode: 'cors', signal: controller.signal }) .then((res) => (res.ok ? res.text() : Promise.reject(res.status))) .then((body) => { // PBS Cache returns the cached bid as a JSON object; decode its creative // and render metadata the same way the Prebid Universal Creative does. const cached = parseCachedBid(body); if (!cached) { - // No renderable creative in the cache payload — decline rather than - // ship a serialized bid document to PUC. Beacons stay unfired. log.warn( `[tsjs-gpt] pbRender bridge: PBS Cache response for '${slotId}' had no renderable adm` ); return; } + if (!stillCurrent()) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_rejected', + slotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: 'stale_cache_completion', + }); + return; + } + if (!billingCapacityAvailable(slotId, matchedBid)) { + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_rejected', + slotId, + generation: exactCandidate?.generation, + bidTraceId: exactCandidate?.traceToken, + reason: 'billing_capacity', + }); + return; + } // Resolve the auction-price macro from the cached clearing price, and // size from the cached bid's own dimensions, falling back to the slot // format only when the cache omits them. @@ -1176,6 +2063,8 @@ export function installTsRenderBridge(): void { cached.price !== undefined ? expandAuctionPriceMacro(cached.adm, cached.price) : cached.adm; + const traceToken = armExpectedRender(exactCandidate, capturedSource); + requestOwner.served = true; port.postMessage( JSON.stringify({ message: 'Prebid Response', @@ -1184,16 +2073,28 @@ export function installTsRenderBridge(): void { renderer: TS_DISPLAY_RENDERER, width: cached.width ?? width, height: cached.height ?? height, + ...(traceToken ? { traceToken } : {}), }) ); fireWinBillingBeacons(slotId, matchedBid); + window.tsjs?.recordAdTrace?.({ + kind: 'pb_render_served', + slotId, + generation: exactCandidate?.generation, + bidTraceId: traceToken, + }); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) .catch((err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; log.warn(`[tsjs-gpt] pbRender bridge: PBS Cache fetch failed for '${slotId}'`, err); }) .finally(() => { - renderingKeys.delete(renderingKey); + if (activeRender.expiryTimer) clearTimeout(activeRender.expiryTimer); + activeCacheRenders.delete(activeRender); + if (latestCacheRenderBySlot.get(slotId) === activeRender) { + latestCacheRenderBySlot.delete(slotId); + } }); }); } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 735334776..63b9fa7ea 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -28,10 +28,10 @@ import 'prebid.js/modules/userId.js'; import './_adapters.generated'; import { log } from '../../core/log'; -import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; +import { buildAdRequest, parseAuctionResponse, parseAuctionTraceSummary } from '../../core/auction'; import { registerApsPrebidRenderer } from '../aps/render'; import type { AuctionBid, AuctionEid } from '../../core/auction'; -import type { AuctionSlot } from '../../core/types'; +import type { AdTraceEventKind, AuctionSlot, TrustedServerBidTrace } from '../../core/types'; import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; @@ -52,6 +52,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_adid', 'hb_cache_host', 'hb_cache_path', + 'ts_trace', ] as const; const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; @@ -227,6 +228,12 @@ export function auctionBidsToPrebidBids(auctionBids: AuctionBid[], bidRequests: meta: { advertiserDomains: bid.adomain, }, + ...(bid.trace + ? { + adserverTargeting: { ts_trace: bid.trace.bidTraceId }, + tsTrace: bid.trace, + } + : {}), }; }); } @@ -265,6 +272,7 @@ type TrustedServerBidRequest = { adUnitCode?: string; code?: string; bidId?: string; + auctionId?: string; }; type TrustedServerRequest = { method: 'POST'; @@ -590,6 +598,155 @@ function serverSideBidderParamsForRefresh( return params; } +function installAdTracePrebidObservers(): void { + const ts = window.tsjs; + if (!ts?.recordAdTrace) return; + const instrumented = pbjs as unknown as { + __tsAdTraceObserved?: boolean; + onEvent?: (event: string, handler: (data: Record) => void) => void; + setTargetingForGPTAsync?: (codes?: string[]) => unknown; + }; + if (instrumented.__tsAdTraceObserved) return; + instrumented.__tsAdTraceObserved = true; + + const record = + (kind: AdTraceEventKind) => + (data: Record = {}): void => { + const nestedBid = + data.bid && typeof data.bid === 'object' + ? (data.bid as Record) + : undefined; + const evidence = nestedBid ?? data; + const slotId = + typeof evidence.adUnitCode === 'string' + ? evidence.adUnitCode + : typeof evidence.code === 'string' + ? evidence.code + : undefined; + const bidder = + typeof evidence.bidderCode === 'string' + ? evidence.bidderCode + : typeof evidence.bidder === 'string' + ? evidence.bidder + : undefined; + const auctionId = + typeof evidence.auctionId === 'string' + ? evidence.auctionId + : typeof data.auctionId === 'string' + ? data.auctionId + : ''; + const requestId = + typeof evidence.requestId === 'string' + ? evidence.requestId + : typeof evidence.adId === 'string' + ? evidence.adId + : ''; + const adId = typeof evidence.adId === 'string' ? evidence.adId : requestId || undefined; + const targeting = evidence.adserverTargeting as Record | undefined; + const serverTrace = evidence.tsTrace as TrustedServerBidTrace | undefined; + const traceToken = + typeof targeting?.ts_trace === 'string' + ? targeting.ts_trace + : typeof (evidence.tsTrace as { bidTraceId?: unknown } | undefined)?.bidTraceId === + 'string' + ? ((evidence.tsTrace as { bidTraceId: string }).bidTraceId as string) + : undefined; + const ledger = (ts.prebidCorrelation ??= []); + if (kind === 'prebid_bid_response' && auctionId && slotId && requestId) { + ledger.push({ + auctionId, + slotId, + requestId, + bidder, + adId, + traceToken, + serverTrace, + events: [], + }); + if (ledger.length > 256) ledger.shift(); + } else if (kind === 'prebid_auction_end' && auctionId) { + const adUnits = Array.isArray(data.adUnits) + ? (data.adUnits as Array>) + : []; + const received = Array.isArray(data.bidsReceived) + ? (data.bidsReceived as Array>) + : []; + const slotIds = new Set(); + for (const unit of adUnits) { + if (typeof unit.code === 'string') slotIds.add(unit.code); + } + for (const bid of received) { + if (typeof bid.adUnitCode === 'string') slotIds.add(bid.adUnitCode); + } + for (const entry of ledger) { + if (entry.auctionId === auctionId) slotIds.add(entry.slotId); + } + const completed = (ts.prebidCompletedAuctions ??= []); + completed.push({ auctionId, slotIds: [...slotIds] }); + if (completed.length > 64) completed.shift(); + } else if (kind !== 'prebid_auction_init') { + const selected = (ts.prebidSelectedParticipants ?? []).filter( + (entry) => performance.now() - entry.selectedAt <= 30_000 + ); + ts.prebidSelectedParticipants = selected; + const selectedMatches = + auctionId && slotId && requestId + ? selected.filter( + (entry) => + entry.auctionId === auctionId && + entry.slotId === slotId && + (entry.requestId === requestId || entry.adId === adId) && + (!traceToken || entry.traceToken === traceToken) + ) + : []; + if (selectedMatches.length === 1) { + const selectedEntry = selectedMatches[0]; + ts.recordAdTrace?.({ + kind, + slotId, + generation: selectedEntry.generation, + bidTraceId: selectedEntry.traceToken, + bidder: selectedEntry.bidder ?? bidder, + }); + if (kind === 'prebid_render_succeeded' || kind === 'prebid_render_failed') { + ts.prebidSelectedParticipants = selected.filter((entry) => entry !== selectedEntry); + } + return; + } + + const matches = ledger.filter( + (entry) => + (!auctionId || entry.auctionId === auctionId) && + (!slotId || entry.slotId === slotId) && + (!requestId || entry.requestId === requestId || entry.adId === adId) + ); + if (matches.length === 1) { + const events = (matches[0].events ??= []); + events.push(kind); + while (events.length > 16) events.shift(); + } + } + ts.recordAdTrace?.({ kind, slotId, bidder }); + }; + + instrumented.onEvent?.('auctionInit', record('prebid_auction_init')); + instrumented.onEvent?.('bidResponse', record('prebid_bid_response')); + instrumented.onEvent?.('bidWon', record('prebid_bid_won')); + instrumented.onEvent?.('auctionEnd', record('prebid_auction_end')); + instrumented.onEvent?.('adRenderSucceeded', record('prebid_render_succeeded')); + instrumented.onEvent?.('adRenderFailed', record('prebid_render_failed')); + + // Observe the actual selection call once. The GPT request-boundary hook reads + // the resulting slot targeting synchronously; this wrapper never caches it. + const original = instrumented.setTargetingForGPTAsync?.bind(pbjs); + if (!original) return; + instrumented.setTargetingForGPTAsync = function (codes?: string[]) { + const result = original(codes); + ts.recordAdTrace?.({ kind: 'prebid_targeting_selected', reason: 'targeting_applied' }); + return result; + }; +} + function clearRefreshTargeting(slot: RefreshGptSlot): void { if (typeof slot.clearTargeting !== 'function') return; @@ -780,6 +937,16 @@ export function installPrebidNpm(config?: Partial): typeof pbjs log.debug('[tsjs-prebid] interpretResponse', { hasSeatbid: !!body?.seatbid }); const auctionBids = parseAuctionResponse(body); const bidRequests = request?.tsjsBidRequests ?? request?.bidRequests ?? []; + const summary = parseAuctionTraceSummary(body); + if (summary && window.tsjs?.recordAdTrace) { + const summaries = (window.tsjs.prebidServerSummaries ??= []); + for (const bidRequest of bidRequests) { + const auctionId = bidRequest.auctionId; + const slotId = bidRequest.adUnitCode ?? bidRequest.code; + if (auctionId && slotId) summaries.push({ auctionId, slotId, summary }); + } + while (summaries.length > 64) summaries.shift(); + } return auctionBidsToPrebidBids(auctionBids, bidRequests); }, }); @@ -964,6 +1131,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // prebid.js via NPM. pbjs.processQueue(); recordUserIdModuleDiagnostics(); + installAdTracePrebidObservers(); // Validate that every client-side bidder has its adapter registered. // Adapters self-register on import, so a missing adapter means the bidder @@ -1106,6 +1274,9 @@ export function installRefreshHandler(timeoutMs = 1500): void { adUnits, bidsBackHandler: () => { pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + targetSlots.forEach((slot) => + window.tsjs?.captureAdTraceRequest?.(slot, 'prebid_refresh') + ); originalRefresh(targetSlots, opts); }, timeout: timeoutMs, diff --git a/crates/trusted-server-js/lib/test/core/ad_trace.test.ts b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts new file mode 100644 index 000000000..0348e8dd7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; +import { + AD_TRACE_MAX_EVENTS, + AD_TRACE_MAX_GENERATIONS, + AD_TRACE_MAX_RENDERS, + AD_TRACE_MAX_SLOTS, + createAdTraceStore, +} from '../../src/core/ad_trace'; + +const BID_TRACE_ID = '550e8400-e29b-41d4-a716-446655440000'; + +describe('ad trace reducer', () => { + it('bounds events, slots, and retained generations', () => { + let now = 0; + const store = createAdTraceStore(() => ++now); + for (let i = 0; i < AD_TRACE_MAX_EVENTS + 1; i++) { + store.record({ kind: 'prebid_auction_init', reason: 'observed' }); + } + for (let i = 0; i < AD_TRACE_MAX_SLOTS + 1; i++) { + store.nextGeneration(`slot-${i}`); + } + for (let i = 0; i < AD_TRACE_MAX_GENERATIONS + 1; i++) { + store.nextGeneration('latest-slot'); + } + + const exported = store.export(); + expect(exported.events).toHaveLength(AD_TRACE_MAX_EVENTS); + expect(exported.metadata.droppedEvents).toBe(1); + expect(exported.slots).toHaveLength(AD_TRACE_MAX_SLOTS); + expect(store.getSlot('latest-slot')?.generations).toHaveLength(AD_TRACE_MAX_GENERATIONS); + expect(exported.metadata.evictedSlots).toBeGreaterThan(0); + }); + + it('keeps the four stages independent and only acknowledges an exact load event', () => { + const store = createAdTraceStore(() => 10); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'ts_winner_observed', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + isEmpty: false, + }); + + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('trusted_server_candidate'); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + const slot = store.getSlot('slot-a'); + expect(slot?.stages.gam).toMatchObject({ + outcome: 'trusted_server_won', + confidence: 'definitive', + }); + expect(slot?.stages.creative).toMatchObject({ + outcome: 'load_acknowledged', + confidence: 'definitive', + }); + }); + + it('updates only the acknowledged retained generation, never the latest generation', () => { + const store = createAdTraceStore(() => 1); + const first = store.nextGeneration('slot-a'); + const second = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation: first, + bidTraceId: BID_TRACE_ID, + }); + + const slot = store.getSlot('slot-a'); + expect(slot?.latestGeneration).toBe(second); + expect(slot?.stages.creative.outcome).toBe('not_observed'); + expect(slot?.generations[0].stages.creative.outcome).toBe('load_acknowledged'); + expect(slot?.generations[1].stages.creative.outcome).toBe('not_observed'); + }); + + it('never downgrades a definitive acknowledgement with a later GPT callback', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + isEmpty: false, + }); + + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('trusted_server_won'); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('load_acknowledged'); + }); + + it('preserves acknowledged terminal history when its generation is later cleaned up', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'slot_destroyed', + }); + + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('trusted_server_won'); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('load_acknowledged'); + }); + + it('does not rewrite a retained generation when the next auction seeds server evidence', () => { + const store = createAdTraceStore(() => 1); + store.record({ + kind: 'ts_winner_observed', + slotId: 'slot-a', + bidTraceId: BID_TRACE_ID, + }); + const first = store.nextGeneration('slot-a'); + store.record({ + kind: 'ts_auction_observed', + slotId: 'slot-a', + outcome: 'no_bid', + confidence: 'definitive', + reason: 'terminal_summary', + }); + const second = store.nextGeneration('slot-a'); + + const slot = store.getSlot('slot-a'); + expect( + slot?.generations.find((item) => item.generation === first)?.stages.trustedServer.outcome + ).toBe('won'); + expect( + slot?.generations.find((item) => item.generation === second)?.stages.trustedServer.outcome + ).toBe('no_bid'); + }); + + it('classifies overlap, client Prebid, APS, no-bid, and superseded states', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'prebid_targeting_selected', + slotId: 'slot-a', + generation, + outcome: 'client_bid_won', + confidence: 'definitive', + reason: 'selected_targeting', + }); + store.record({ + kind: 'prebid_bid_won', + slotId: 'slot-a', + generation, + }); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: false, + }); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('client_prebid_candidate'); + + store.record({ kind: 'aps_display_bids_set', slotId: 'slot-a', generation }); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('client_prebid_candidate'); + + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'slot_destroyed', + }); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('client_prebid_candidate'); + }); + + it('does not downgrade definitive stage evidence during service or cleanup', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ kind: 'prebid_render_failed', slotId: 'slot-a', generation }); + store.record({ kind: 'pb_render_served', slotId: 'slot-a', generation }); + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'navigation', + }); + + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'render_failed', + confidence: 'definitive', + }); + }); + + it('does not downgrade a definitive empty render outcome', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: true, + }); + store.record({ + kind: 'generation_superseded', + slotId: 'slot-a', + generation, + reason: 'navigation', + }); + store.record({ kind: 'pb_render_served', slotId: 'slot-a', generation }); + + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'empty', + confidence: 'definitive', + }); + }); + + it('enriches one bounded render record and keeps visibility independent', () => { + let now = 0; + const store = createAdTraceStore(() => ++now); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: false, + }); + store.record({ + kind: 'pb_render_served', + slotId: 'slot-a', + generation, + reason: 'pb_render_response', + }); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + store.updateVisibility('slot-a', generation, 'hidden'); + + const timeline = store.getRenderTimeline(); + expect(timeline).toHaveLength(1); + expect(timeline[0]).toMatchObject({ + sequence: 1, + outcome: 'confirmed', + confidence: 'definitive', + visibility: 'hidden', + }); + store.updateVisibility('slot-a', generation, 'visible'); + expect(store.getRenderTimeline()[0]).toMatchObject({ + sequence: 1, + outcome: 'confirmed', + confidence: 'definitive', + visibility: 'visible', + }); + }); + + it('dispatches a frozen privacy-safe render event', () => { + const store = createAdTraceStore(() => 1); + const observed: unknown[] = []; + const listener = (event: Event) => observed.push((event as CustomEvent).detail); + window.addEventListener('tsjs:adRendered', listener); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'pb_render_served', + slotId: 'slot-a', + generation, + reason: 'pb_render_response', + rawUrl: 'https://private.example', + } as never); + window.removeEventListener('tsjs:adRendered', listener); + + expect(observed).toHaveLength(1); + expect(Object.isFrozen(observed[0])).toBe(true); + expect(JSON.stringify(observed[0])).not.toContain('private.example'); + }); + + it('bounds the render timeline without duplicating impression generations', () => { + const store = createAdTraceStore(() => 1); + for (let i = 0; i < AD_TRACE_MAX_RENDERS + 1; i++) { + const slotId = `render-${i}`; + const generation = store.nextGeneration(slotId); + store.record({ kind: 'gpt_request_started', slotId, generation }); + } + expect(store.getRenderTimeline()).toHaveLength(AD_TRACE_MAX_RENDERS); + expect(store.getRenderTimeline()[0].slotId).toBe('render-1'); + }); + + it('rejects malformed runtime event kinds and confidence values', () => { + const store = createAdTraceStore(() => 1); + store.record({ kind: 'not-a-real-kind', slotId: 'slot-a' } as never); + store.record({ + kind: 'ts_winner_observed', + slotId: 'slot-a', + confidence: 'certain', + } as never); + expect(store.getEvents()).toHaveLength(0); + }); + + it('exports an immutable sanitized clone', () => { + const store = createAdTraceStore(() => 1); + store.record({ + kind: 'pb_render_rejected', + slotId: 'slot-a', + reason: 'missing_generation', + // Ensure unknown private fields cannot enter the public export. + rawUrl: 'https://private.example/path', + } as never); + + const exported = store.export(); + expect(Object.isFrozen(exported)).toBe(true); + expect(JSON.stringify(exported)).not.toContain('private.example'); + expect(() => exported.events.push({} as never)).toThrow(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 7e9bb2947..f58d6bea0 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/core/auction'; +import { + buildAdRequest, + parseAuctionResponse, + parseAuctionTraceSummary, + sendAuction, +} from '../../src/core/auction'; import envelope from '../fixtures/aps-renderer-v1.json'; function apsRenderer(creativeId?: string) { @@ -299,6 +304,100 @@ describe('auction/parseAuctionResponse', () => { expect(parseAuctionResponse({ seatbid: [] })).toEqual([]); }); + it('strictly joins valid root and bid traces without changing legacy fields', () => { + const body = { + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: '650e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome: 'completed', + }, + }, + }, + seatbid: [ + { + seat: 'example-bidder', + bid: [ + { + impid: 'slot-1', + price: 1.5, + ext: { + trusted_server: { + trace: { + version: 1, + bid_trace_id: '550e8400-e29b-41d4-a716-446655440000', + slot_id: 'slot-1', + provider: 'prebid', + bidder: 'example-bidder', + }, + }, + }, + }, + ], + }, + ], + }; + + expect(parseAuctionTraceSummary(body)).toEqual({ + version: 1, + auctionTraceId: '650e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome: 'completed', + }); + expect(parseAuctionResponse(body)[0].trace).toEqual({ + version: 1, + auctionTraceId: '650e8400-e29b-41d4-a716-446655440000', + bidTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + slotId: 'slot-1', + provider: 'prebid', + bidder: 'example-bidder', + }); + }); + + it('ignores malformed, contradictory, mismatched, and oversized trace fields', () => { + const body = { + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: '650e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome: 'no_bid', + }, + }, + }, + seatbid: [ + { + seat: 'seat', + bid: [ + { + impid: 'slot-1', + price: 1, + ext: { + trusted_server: { + trace: { + version: 1, + bid_trace_id: '550e8400-e29b-41d4-a716-446655440000', + slot_id: 'different-slot', + provider: 'p'.repeat(65), + bidder: 'seat', + }, + }, + }, + }, + ], + }, + ], + }; + expect(parseAuctionTraceSummary(body)?.outcome).toBe('no_bid'); + expect(parseAuctionResponse(body)[0].trace).toBeUndefined(); + body.ext.trusted_server.trace.auction_trace_id = 'not-a-uuid'; + expect(parseAuctionTraceSummary(body)).toBeUndefined(); + }); + it('defaults missing fields gracefully', () => { const body = { seatbid: [{ bid: [{ impid: 'slot-1', price: 1.5 }] }], @@ -353,7 +452,7 @@ describe('auction/sendAuction', () => { ], }; - const bids = await sendAuction('/auction', request); + const result = await sendAuction('/auction', request); expect(globalThis.fetch).toHaveBeenCalledWith( '/auction', @@ -363,18 +462,46 @@ describe('auction/sendAuction', () => { body: JSON.stringify(request), }) ); - expect(bids).toHaveLength(1); - expect(bids[0].price).toBe(2.5); + expect(result.kind).toBe('ok'); + if (result.kind !== 'ok') throw new Error('expected successful auction'); + expect(result.bids).toHaveLength(1); + expect(result.bids[0].price).toBe(2.5); }); - it('returns empty array on network error', async () => { + it('distinguishes a network error from a valid empty auction', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error')) as any; - const bids = await sendAuction('/auction', { adUnits: [] }); - expect(bids).toEqual([]); + const result = await sendAuction('/auction', { adUnits: [] }); + expect(result).toEqual({ kind: 'transport_error', reason: 'network' }); + }); + + it('accepts legacy empty but rejects malformed seatbid collections', async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({}), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ seatbid: {} }), + }) as any; + + await expect(sendAuction('/auction', { adUnits: [] })).resolves.toEqual({ + kind: 'ok', + bids: [], + }); + await expect(sendAuction('/auction', { adUnits: [] })).resolves.toEqual({ + kind: 'invalid_response', + reason: 'invalid_shape', + }); }); - it('returns empty array for non-JSON response', async () => { + it('distinguishes a non-JSON response', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, @@ -382,11 +509,11 @@ describe('auction/sendAuction', () => { json: async () => ({}), }) as any; - const bids = await sendAuction('/auction', { adUnits: [] }); - expect(bids).toEqual([]); + const result = await sendAuction('/auction', { adUnits: [] }); + expect(result).toEqual({ kind: 'invalid_response', reason: 'non_json' }); }); - it('returns empty array for non-OK response', async () => { + it('distinguishes a non-OK response', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, @@ -394,7 +521,7 @@ describe('auction/sendAuction', () => { json: async () => ({}), }) as any; - const bids = await sendAuction('/auction', { adUnits: [] }); - expect(bids).toEqual([]); + const result = await sendAuction('/auction', { adUnits: [] }); + expect(result).toEqual({ kind: 'transport_error', reason: 'http' }); }); }); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 8dffd825b..dc3cf9b68 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -11,6 +11,7 @@ describe('request.requestAds', () => { beforeEach(async () => { await vi.resetModules(); document.body.innerHTML = ''; + delete window.tsjs; originalFetch = globalThis.fetch; }); @@ -319,9 +320,8 @@ describe('request.requestAds', () => { expect(JSON.stringify(rejectionCall)).not.toContain('[object Object]'); }); - it('does not blank the slot when a later bid for the same slot is rejected', async () => { - // Regression: multi-bid scenario where a rejected bid must not erase an earlier - // successful render into the same slot. + it('rejects an ambiguous multi-winner response without blanking the slot', async () => { + // A final auction response must contain at most one winner per requested slot. const goodCreative = '
Safe Ad
'; (globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, @@ -345,16 +345,14 @@ describe('request.requestAds', () => { const { addAdUnits } = await import('../../src/core/registry'); const { requestAds } = await import('../../src/core/request'); - document.body.innerHTML = '
'; + document.body.innerHTML = '
existing
'; addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); await flushRequestAds(); - // The good creative should have rendered; the bad one should not have blanked it. - const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; - expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(goodCreative); + expect(document.querySelector('#slot1 iframe')).toBeNull(); + expect(document.querySelector('#slot1')?.textContent).toContain('existing'); }); it('rejects creatives that sanitize to empty markup', async () => { @@ -398,6 +396,134 @@ describe('request.requestAds', () => { ); }); + it('keeps the latest direct owner when overlapping responses resolve out of order', async () => { + const resolves: Array<(response: Response) => void> = []; + (globalThis as any).fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolves.push(resolve); + }) + ); + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2), + } as any; + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + document.body.innerHTML = '
existing
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + requestAds(); + expect(resolves).toHaveLength(2); + const response = (creative: string) => + ({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + seatbid: [{ seat: 'trusted-server', bid: [{ impid: 'slot1', adm: creative }] }], + }), + }) as Response; + + resolves[1](response('
new owner
')); + await flushRequestAds(); + resolves[0](response('
stale owner
')); + await flushRequestAds(); + + const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement; + expect(iframe.srcdoc).toContain('new owner'); + expect(iframe.srcdoc).not.toContain('stale owner'); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'direct_render_rejected', + generation: 1, + reason: 'direct_owner_replaced', + }) + ); + }); + + it('records an exact direct auction winner, placement, and iframe load', async () => { + const auctionTraceId = '550e8400-e29b-41d4-a716-446655440000'; + const bidTraceId = '123e4567-e89b-42d3-a456-426614174000'; + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValue(1), + } as any; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: auctionTraceId, + source: 'auction_api', + outcome: 'completed', + }, + }, + }, + seatbid: [ + { + seat: 'trusted-server', + bid: [ + { + impid: 'slot1', + adm: '
direct
', + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: auctionTraceId, + bid_trace_id: bidTraceId, + source: 'auction_api', + slot_id: 'slot1', + provider: 'prebid', + bidder: 'example', + }, + }, + }, + }, + ], + }, + ], + }), + }); + + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'ts_winner_observed', + generation: 1, + auctionTraceId, + bidTraceId, + }) + ); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'pb_render_served', reason: 'direct_iframe_created' }) + ); + + const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement; + iframe.dispatchEvent(new Event('load')); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_load_acknowledged', + generation: 1, + reason: 'direct_iframe_load', + }) + ); + }); + it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML (globalThis as any).fetch = vi.fn().mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts new file mode 100644 index 000000000..a3eaa7d6f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('ad_trace integration gate', () => { + beforeEach(() => { + vi.resetModules(); + document.getElementById('ts-ad-trace-overlay')?.remove(); + delete window.__tsjs_adTraceActive; + delete window.tsjs; + }); + + afterEach(() => { + document.getElementById('ts-ad-trace-overlay')?.remove(); + delete window.__tsjs_adTraceActive; + delete window.tsjs; + }); + + it('leaves API and private recorders absent without the server bootstrap', async () => { + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(false); + expect(window.tsjs?.adTrace).toBeUndefined(); + expect(window.tsjs?.recordAdTrace).toBeUndefined(); + }); + + it('installs one immutable API and consumes the exact bootstrap', async () => { + window.__tsjs_adTraceActive = true; + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(true); + expect(window.__tsjs_adTraceActive).toBeUndefined(); + expect(Object.isFrozen(window.tsjs?.adTrace)).toBe(true); + expect(typeof window.tsjs?.recordAdTrace).toBe('function'); + expect(document.querySelectorAll('#ts-ad-trace-overlay')).toHaveLength(1); + expect(installAdTrace()).toBe(true); + expect(document.querySelectorAll('#ts-ad-trace-overlay')).toHaveLength(1); + }); + + it('does not accept the legacy tester cookie without bootstrap', async () => { + document.cookie = 'ts-tester=true; Path=/'; + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts new file mode 100644 index 000000000..0a5aaa217 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { installAdTraceOverlay } from '../../../src/integrations/ad_trace/overlay'; +import type { AdTraceApi } from '../../../src/core/types'; + +function api(): AdTraceApi { + const slot = { + slotId: 'slot-a', + latestGeneration: 1, + generations: [], + stages: { + trustedServer: { outcome: 'won', confidence: 'definitive', reason: 'winner' }, + prebid: { outcome: 'not_run', confidence: 'definitive', reason: 'direct' }, + gam: { outcome: 'trusted_server_candidate', confidence: 'probable', reason: 'render' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + }, + } as const; + const renders = [ + { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'gam_only', + confidence: 'probable', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }, + ] as const; + return { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => renders as any, + export: () => ({ + version: 1, + slots: [slot as any], + events: [], + renders: renders as any, + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }), + }; +} + +describe('ad trace overlay lifecycle', () => { + afterEach(() => { + document.getElementById('ts-ad-trace-overlay')?.remove(); + document.getElementById('slot-prefix-rendered')?.remove(); + delete window.tsjs; + vi.restoreAllMocks(); + }); + + it('finds prefix slots, observes resize, and coalesces animation frames', () => { + const element = document.createElement('div'); + element.id = 'slot-prefix-rendered'; + const rect = vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + left: 10, + top: 20, + width: 300, + height: 250, + } as DOMRect); + document.body.appendChild(element); + const updateVisibility = vi.fn(); + window.tsjs = { + adSlots: [{ id: 'slot-a', div_id: 'slot-prefix' }], + getAdTraceElement: () => element, + updateAdTraceVisibility: updateVisibility, + } as any; + + const observe = vi.fn(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe = observe; + unobserve = vi.fn(); + disconnect = vi.fn(); + } + ); + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + let subscriber: (() => void) | undefined; + installAdTraceOverlay(api(), (listener) => { + subscriber = listener; + return vi.fn(); + }); + + expect(rect).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledWith(element); + expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'visible'); + expect(element.getAttribute('data-ts-trace-seq')).toBe('1'); + expect(element.getAttribute('data-ts-trace-outcome')).toBe('gam_only'); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + subscriber?.(); + expect(frames).toHaveLength(1); + frames.shift()?.(1); + expect(rect).toHaveBeenCalledTimes(2); + + const replacement = document.createElement('div'); + replacement.id = element.id; + element.replaceWith(replacement); + subscriber?.(); + frames.shift()?.(2); + expect(replacement.hasAttribute('data-ts-trace-seq')).toBe(false); + expect(element.hasAttribute('data-ts-trace-seq')).toBe(false); + expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'disconnected'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index c790d37a0..7232668ab 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -1074,15 +1074,30 @@ describe('installTsRenderBridge', () => { afterEach(() => { vi.unstubAllGlobals(); document.getElementById('div-header')?.remove(); + document.getElementById('div-sidebar')?.remove(); delete (window as TestWindow).tsjs; }); + function capturePrivateOwner(slotId: string, divId: string): void { + const ts = (window as TestWindow).tsjs!; + const bid = ts.bids?.[slotId]; + ts.captureAdTraceRequest?.( + { + getSlotElementId: () => divId, + getTargeting: () => [], + }, + 'test_request', + { slotId, adId: bid?.hb_adid, bid } + ); + } + function createTrustedSlotIframe(): Window { const slot = document.createElement('div'); slot.id = 'div-header'; const iframe = document.createElement('iframe'); slot.appendChild(iframe); document.body.appendChild(slot); + capturePrivateOwner('homepage_header', slot.id); return iframe.contentWindow!; } @@ -1427,7 +1442,7 @@ describe('installTsRenderBridge', () => { expect(fetchStub).toHaveBeenCalledWith( 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } + expect.objectContaining({ mode: 'cors', signal: expect.any(AbortSignal) }) ); expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); @@ -1449,6 +1464,8 @@ describe('installTsRenderBridge', () => { }) as unknown as MessageEvent ); await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchStub).toHaveBeenCalledTimes(1); + expect(portMessages).toHaveLength(1); expect(beaconSpy).toHaveBeenCalledTimes(2); beaconSpy.mockRestore(); }); @@ -1590,12 +1607,8 @@ describe('installTsRenderBridge', () => { }); it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { - // Concurrent render double-fire guard: two 'Prebid Request' messages for the - // same adId can arrive before the first cache fetch settles. The in-flight - // `renderingAdIds` gate must collapse them to a single fetch — the persistent - // firedBeacons dedup only engages after a fetch resolves, so it cannot stop - // the second fetch on its own. Deferring the fetch keeps both messages in the - // window where only the in-flight gate can prevent the duplicate. + // Two duplicate requests from the exact same private owner collapse to one + // fetch while it remains current. const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const mockAd = '
Test Creative
'; let resolveFetch: (value: Response) => void = () => {}; @@ -1642,6 +1655,263 @@ describe('installTsRenderBridge', () => { beaconSpy.mockRestore(); }); + it('supersedes a same-adId cache owner from a different source', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const resolves: Array<(value: Response) => void> = []; + fetchStub.mockImplementation( + () => + new Promise((resolve) => { + resolves.push(resolve); + }) + ); + const bridgeListener = await captureBridgeListener(); + const oldPort = { postMessage: vi.fn() }; + const newPort = { postMessage: vi.fn() }; + const oldSource = createTrustedSlotIframe(); + const newFrame = document.createElement('iframe'); + document.getElementById('div-header')?.appendChild(newFrame); + const newSource = newFrame.contentWindow!; + + const dispatch = (source: Window, port: { postMessage: ReturnType }): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + dispatch(oldSource, oldPort); + dispatch(newSource, newPort); + expect(fetchStub).toHaveBeenCalledTimes(2); + + resolves[0]({ ok: true, text: () => Promise.resolve('
old
') } as Response); + resolves[1]({ ok: true, text: () => Promise.resolve('
new
') } as Response); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(oldPort.postMessage).not.toHaveBeenCalled(); + expect(newPort.postMessage).toHaveBeenCalledTimes(1); + expect(beaconSpy).toHaveBeenCalledTimes(2); + beaconSpy.mockRestore(); + }); + + it('allows concurrent same-adId owners in different slots', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const ts = (window as TestWindow).tsjs!; + ts.bids!.sidebar = { + ...ts.bids!.homepage_header, + nurl: 'https://ssp.example/sidebar-win', + burl: 'https://ssp.example/sidebar-bill', + }; + ts.adSlots!.push({ + id: 'sidebar', + formats: [[300, 250]], + gam_unit_path: '/a/b/sidebar', + div_id: 'div-sidebar', + targeting: {}, + }); + const resolves: Array<(value: Response) => void> = []; + fetchStub.mockImplementation( + () => + new Promise((resolve) => { + resolves.push(resolve); + }) + ); + const bridgeListener = await captureBridgeListener(); + const headerPort = { postMessage: vi.fn() }; + const sidebarPort = { postMessage: vi.fn() }; + const headerSource = createTrustedSlotIframe(); + const sidebar = document.createElement('div'); + sidebar.id = 'div-sidebar'; + const sidebarFrame = document.createElement('iframe'); + sidebar.appendChild(sidebarFrame); + document.body.appendChild(sidebar); + capturePrivateOwner('sidebar', sidebar.id); + + const dispatch = (source: Window, port: { postMessage: ReturnType }): void => { + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + }; + dispatch(headerSource, headerPort); + dispatch(sidebarFrame.contentWindow!, sidebarPort); + resolves.forEach((resolve, index) => + resolve({ + ok: true, + text: () => Promise.resolve(`
creative ${index}
`), + } as Response) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(headerPort.postMessage).toHaveBeenCalledTimes(1); + expect(sidebarPort.postMessage).toHaveBeenCalledTimes(1); + expect(beaconSpy).toHaveBeenCalledTimes(4); + beaconSpy.mockRestore(); + }); + + it('blocks a late TS message after navigation before page-bids applies', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + const port = { postMessage: vi.fn() }; + const stop = vi.fn(); + + window.dispatchEvent(new PopStateEvent('popstate')); + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: stop, + }) as unknown as MessageEvent + ); + + expect(stop).toHaveBeenCalledOnce(); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(fetchStub).not.toHaveBeenCalled(); + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + + it('blocks an old traced message after a newer request capture', async () => { + const ts = (window as TestWindow).tsjs!; + ts.recordAdTrace = vi.fn(); + ts.nextAdTraceGeneration = vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2); + const bridgeListener = await captureBridgeListener(); + const source = createTrustedSlotIframe(); + ts.bids!.homepage_header = { + ...ts.bids!.homepage_header, + hb_adid: 'new-cache-uuid', + }; + capturePrivateOwner('homepage_header', 'div-header'); + const port = { postMessage: vi.fn() }; + const stop = vi.fn(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: stop, + }) as unknown as MessageEvent + ); + + expect(stop).toHaveBeenCalledOnce(); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + it('drops a detached stale cache completion without responding or billing', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let resolveFetch: (value: Response) => void = () => {}; + fetchStub.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const bridgeListener = await captureBridgeListener(); + const port = { postMessage: vi.fn() }; + const source = createTrustedSlotIframe(); + + bridgeListener( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + document.getElementById('div-header')?.remove(); + resolveFetch({ + ok: true, + text: () => Promise.resolve('
stale
'), + } as Response); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); + + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const debugAdm = '
Debug Creative
'; + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'debug-adid', + hb_bidder: 'mocktioneer', + hb_pb: '0.20', + nurl: 'https://debug.example/win', + burl: 'https://debug.example/bill', + adm: debugAdm, + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + }; + + let bridgeListener: ((e: MessageEvent) => unknown) | undefined; + const origAdd = window.addEventListener.bind(window); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); + await import('../../../src/integrations/gpt/index'); + addSpy.mockRestore(); + + expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); + + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + const source = createTrustedSlotIframe(); + + bridgeListener!( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), + ports: [fakePort], + source, + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).not.toHaveBeenCalled(); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.message).toBe('Prebid Response'); + expect(parsed.adId).toBe('debug-adid'); + expect(parsed.ad).toBe(debugAdm); + expect(parsed.width).toBe(728); + expect(parsed.height).toBe(90); + expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); + expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); + expect(beaconSpy).toHaveBeenCalledTimes(2); + beaconSpy.mockRestore(); + }); + it('does not let one slot block a PBS Cache render for another slot sharing an adId', async () => { // The in-flight guard must be scoped to the requesting slot, not the shared // adId: two distinct slots sharing one hb_adid must each fetch and render. @@ -1696,6 +1966,8 @@ describe('installTsRenderBridge', () => { }; const sourceA = mkIframe('div-a'); const sourceB = mkIframe('div-b'); + capturePrivateOwner('slot_a', 'div-a'); + capturePrivateOwner('slot_b', 'div-b'); try { for (const source of [sourceA, sourceB]) { @@ -1905,6 +2177,7 @@ describe('installTsRenderBridge', () => { slot.appendChild(iframe); document.body.appendChild(slot); const source = iframe.contentWindow!; + capturePrivateOwner('homepage_in_content', 'div-in-content'); try { bridgeListener( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts new file mode 100644 index 000000000..50d051eaf --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const OLD_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; +const NEW_TOKEN = '650e8400-e29b-41d4-a716-446655440000'; + +function slotWithTargeting(values: Record) { + return { + getSlotElementId: () => 'div-header', + getTargeting: (key: string) => (values[key] ? [values[key]] : []), + }; +} + +function trustedSource(): Window { + const root = document.createElement('div'); + root.id = 'div-header'; + const iframe = document.createElement('iframe'); + root.appendChild(iframe); + document.body.appendChild(root); + return iframe.contentWindow!; +} + +describe('GPT immutable ad trace render attribution', () => { + let bridge: (event: MessageEvent) => void; + let module: typeof import('../../../src/integrations/gpt/index'); + let record: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + record = vi.fn(); + Object.defineProperty(navigator, 'sendBeacon', { + value: vi.fn(), + configurable: true, + writable: true, + }); + let generation = 0; + window.tsjs = { + recordAdTrace: record, + nextAdTraceGeneration: () => ++generation, + divToSlotId: { 'div-header': 'slot-a' }, + adSlots: [ + { + id: 'slot-a', + div_id: 'div-header', + gam_unit_path: '/123/example', + formats: [[300, 250]], + }, + ], + bids: { + 'slot-a': { + hb_adid: 'old-ad-id', + adm: '
Old creative
', + nurl: 'https://billing.example/win', + burl: 'https://billing.example/bill', + trace: { + version: 1, + auctionTraceId: '750e8400-e29b-41d4-a716-446655440000', + bidTraceId: OLD_TOKEN, + source: 'initial_navigation', + slotId: 'slot-a', + provider: 'prebid', + bidder: 'example-bidder', + }, + }, + }, + } as any; + const originalAdd = window.addEventListener.bind(window); + const spy = vi + .spyOn(window, 'addEventListener') + .mockImplementation((type, listener, options) => { + if (type === 'message') bridge = listener as (event: MessageEvent) => void; + originalAdd(type, listener, options); + }); + module = await import('../../../src/integrations/gpt/index'); + spy.mockRestore(); + }); + + afterEach(() => { + document.getElementById('div-header')?.remove(); + delete window.tsjs; + vi.restoreAllMocks(); + }); + + it('preserves authoritative missing values in a queued boundary snapshot', () => { + const source = trustedSource(); + const port = { postMessage: vi.fn() }; + const stop = vi.fn(); + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest(slotWithTargeting({ hb_adid: 'old-ad-id' }) as any, 'bootstrap', { + slotId: 'slot-a', + bidder: undefined, + adId: undefined, + traceToken: undefined, + bid: undefined, + }); + + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: stop, + }) as unknown as MessageEvent + ); + + expect(stop).toHaveBeenCalledOnce(); + expect(port.postMessage).not.toHaveBeenCalled(); + expect(beacon).not.toHaveBeenCalled(); + }); + + it('never pairs a new client or refreshed TS adId with the stale live bid payload', () => { + const source = trustedSource(); + const port = { postMessage: vi.fn() }; + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + + for (const targeting of [ + { hb_adid: 'client-ad-id', hb_bidder: 'client-bidder' }, + { hb_adid: 'new-ts-ad-id', hb_bidder: 'example-bidder', ts_trace: NEW_TOKEN }, + ]) { + window.tsjs!.prebidCorrelation = [ + { + auctionId: 'auction-2', + slotId: 'slot-a', + requestId: 'request-2', + adId: targeting.hb_adid, + bidder: targeting.hb_bidder, + ...(targeting.ts_trace ? { traceToken: targeting.ts_trace } : {}), + ...(!targeting.ts_trace ? { events: ['prebid_bid_won' as const] } : {}), + }, + ]; + module.captureAdTraceRequest(slotWithTargeting(targeting) as any, 'prebid_refresh'); + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: targeting.hb_adid }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + } + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(beacon).not.toHaveBeenCalled(); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_targeting_selected', outcome: 'client_bid_won' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_bid_won', generation: 1 }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'prebid_targeting_selected', + outcome: 'won', + bidTraceId: NEW_TOKEN, + }) + ); + + window.tsjs!.prebidCorrelation = [ + { + auctionId: 'auction-3', + slotId: 'slot-a', + requestId: 'client-request', + adId: 'winning-client-ad', + bidder: 'client-bidder', + }, + { + auctionId: 'auction-3', + slotId: 'slot-a', + requestId: 'ts-request', + adId: 'losing-ts-ad', + bidder: 'trustedServer', + traceToken: NEW_TOKEN, + }, + ]; + module.captureAdTraceRequest( + slotWithTargeting({ hb_adid: 'winning-client-ad', hb_bidder: 'client-bidder' }) as any, + 'prebid_refresh' + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_targeting_selected', outcome: 'lost' }) + ); + }); + + it('serves, bills once, and acknowledges only the exact immutable generation/source/token', () => { + const source = trustedSource(); + const foreignSource = window; + const port = { postMessage: vi.fn() }; + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest( + slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }) as any, + 'display' + ); + + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + const response = JSON.parse(port.postMessage.mock.calls[0][0]); + expect(response.traceToken).toBe(OLD_TOKEN); + expect(response.ad).toBe('
Old creative
'); + expect(beacon).toHaveBeenCalledTimes(2); + + // A newer generation does not steal or invalidate the retained exact ack. + const nextSlot = slotWithTargeting({ hb_adid: 'client-next', hb_bidder: 'client-bidder' }); + module.captureAdTraceRequest(nextSlot as any, 'prebid_refresh'); + + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source: foreignSource, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: NEW_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_load_acknowledged', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + expect(beacon).toHaveBeenCalledTimes(2); + + module.supersedeAdTraceSlot(nextSlot as any, 'slot_destroyed'); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'generation_superseded', + generation: 2, + reason: 'slot_destroyed', + }) + ); + }); + + it('rejects acknowledgements after the exact slot generation is superseded', () => { + const source = trustedSource(); + const slot = slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }); + const port = { postMessage: vi.fn() }; + vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest(slot as any, 'display'); + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + module.supersedeAdTraceSlot(slot as any, 'slot_destroyed'); + record.mockClear(); + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'pb_render_rejected', reason: 'invalid_acknowledgement' }) + ); + }); + + it('expires pending acknowledgements after thirty seconds', () => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const source = trustedSource(); + const slot = slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }); + const port = { postMessage: vi.fn() }; + vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + module.captureAdTraceRequest(slot as any, 'display'); + bridge( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'old-ad-id' }), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }) as unknown as MessageEvent + ); + now = 30_001; + record.mockClear(); + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken: OLD_TOKEN }, + source, + }) as unknown as MessageEvent + ); + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'generation_superseded', reason: 'ack_expired' }) + ); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index 406c6d1f5..2f21da832 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -240,6 +240,14 @@ describe('GPT – installTsAdInit', () => { ['ts_initial', ['1']], ['pos', ['old-pos']], ]); + const clearTargeting = vi.fn((key?: string) => { + if (key) { + slotTargeting.delete(key); + } else { + slotTargeting.clear(); + } + return gptSlot; + }); const gptSlot: any = { getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), @@ -247,14 +255,7 @@ describe('GPT – installTsAdInit', () => { slotTargeting.set(key, Array.isArray(value) ? value : [value]); return gptSlot; }), - clearTargeting: vi.fn((key?: string) => { - if (key) { - slotTargeting.delete(key); - } else { - slotTargeting.clear(); - } - return gptSlot; - }), + clearTargeting, }; const pubads = { getSlots: vi.fn(() => [gptSlot]), @@ -295,13 +296,13 @@ describe('GPT – installTsAdInit', () => { installTsAdInit(); (window as any).tsjs.adInit(); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('pos'); + expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); + expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); + expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(clearTargeting).toHaveBeenCalledWith('pos'); expect(slotTargeting.get('hb_pb')).toBeUndefined(); expect(slotTargeting.get('hb_bidder')).toBeUndefined(); expect(slotTargeting.get('hb_adid')).toBeUndefined(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 00cf99dd0..4434f1256 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -211,6 +211,39 @@ describe('prebid/auctionBidsToPrebidBids', () => { ); }); + it('adds adapter targeting only for a validated Trusted Server trace', () => { + const traced: AuctionBid = { + impid: 'slot-traced', + adm: '
Ad
', + price: 2, + width: 300, + height: 250, + seat: 'example-bidder', + creativeId: 'creative-1', + adomain: [], + trace: { + version: 1, + auctionTraceId: '650e8400-e29b-41d4-a716-446655440000', + bidTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + slotId: 'slot-traced', + provider: 'prebid', + bidder: 'example-bidder', + }, + }; + + const [bid] = auctionBidsToPrebidBids( + [traced], + [{ adUnitCode: 'slot-traced', bidId: 'request-1' }] + ); + expect(bid.adserverTargeting).toEqual({ + ts_trace: '550e8400-e29b-41d4-a716-446655440000', + }); + expect(auctionBidsToPrebidBids([{ ...traced, trace: undefined }], [])[0]).not.toHaveProperty( + 'adserverTargeting' + ); + }); + it('falls back to impid when no matching bidRequest found', () => { const auctionBids: AuctionBid[] = [ { @@ -280,8 +313,9 @@ describe('prebid/installPrebidNpm', () => { document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete (window as any).__tsjs_prebid; delete (window as any).__tsjs_prebid_diagnostics; - delete (window as any).tsjs; delete (mockPbjs as any).__tsApsBidResponseListenerInstalled; + delete (mockPbjs as any).__tsAdTraceObserved; + delete window.tsjs; }); afterEach(() => { @@ -940,6 +974,50 @@ describe('prebid/installPrebidNpm', () => { expect(document.cookie).toBe(''); }); + + it('joins late winner and render events to the retained selected generation', () => { + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + prebidSelectedParticipants: [ + { + auctionId: 'auction-1', + slotId: 'slot-a', + requestId: 'request-1', + adId: 'ad-1', + bidder: 'client-bidder', + generation: 7, + selectedAt: performance.now(), + }, + ], + } as any; + installPrebidNpm(); + const handlers = new Map) => void>( + mockOnEvent.mock.calls.map(([event, handler]) => [event, handler]) + ); + const bid = { + auctionId: 'auction-1', + adUnitCode: 'slot-a', + requestId: 'request-1', + adId: 'ad-1', + bidderCode: 'client-bidder', + }; + + handlers.get('bidWon')?.(bid); + handlers.get('adRenderSucceeded')?.({ bid }); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'prebid_bid_won', generation: 7, slotId: 'slot-a' }) + ); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'prebid_render_succeeded', + generation: 7, + slotId: 'slot-a', + }) + ); + expect(window.tsjs.prebidSelectedParticipants).toEqual([]); + }); }); }); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 9e8c70b24..1912de6c7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -998,6 +998,23 @@ apply when the integration section exists in `trusted-server.toml`. | --------- | ------- | ------------------------------ | | `enabled` | Boolean | Enable/disable the integration | +### Ad Trace Integration + +**Section**: `[integrations.ad_trace]` + +| Field | Type | Default | Description | +| --------- | ------- | ------- | ------------------------------------------------ | +| `enabled` | Boolean | `false` | Include tester-only auction trace browser support | + +Browser-visible auction IDs, bid IDs, targeting, API state, and the console require this setting plus an activated browser session. Visit a publisher page with the exact query `?ts_console=true` or `?ts_console=1`; Trusted Server enables the first response and sets a host-only session cookie automatically. Use `?ts_console=false` or `?ts_console=0` to clear the session. The reserved query is removed from downstream requests and cleaned from eligible HTML URLs. Active trace responses are private and non-storeable. + +The integration is disabled by default. The query is a self-service diagnostic toggle, not authorization, and does nothing without the explicit configuration gate. The console never exposes the internal auction request ID, identity data, consent strings, page URLs, partner notification URLs, cache coordinates, raw targeting, or creative markup. A creative marked `confirmed` means its exact Trusted Server renderer iframe load was acknowledged; it does not claim viewability or arbitrary advertiser JavaScript completion. + +```toml +[integrations.ad_trace] +enabled = false +``` + ### Prebid Integration **Section**: `[integrations.prebid]` diff --git a/scripts/generate-integration-viceroy-configs.sh b/scripts/generate-integration-viceroy-configs.sh index 761d06926..97ee870a0 100755 --- a/scripts/generate-integration-viceroy-configs.sh +++ b/scripts/generate-integration-viceroy-configs.sh @@ -13,6 +13,7 @@ ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/target/integration-test-artifacts}" CONFIG_DIR="$ARTIFACTS_DIR/configs" TEMPLATE_PATH="crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml" APP_CONFIG_PATH="crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml" +AD_TRACE_APP_CONFIG_PATH="crates/trusted-server-integration-tests/fixtures/configs/trusted-server.ad-trace.integration.toml" INTEGRATION_TARGET_DIR="crates/trusted-server-integration-tests/target" ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" @@ -41,3 +42,9 @@ fi --app-config "$APP_CONFIG_PATH" \ --output "$CONFIG_DIR/viceroy.toml" \ --origin-url "$ORIGIN_URL" + +"$GENERATOR_BIN" \ + --template "$TEMPLATE_PATH" \ + --app-config "$AD_TRACE_APP_CONFIG_PATH" \ + --output "$CONFIG_DIR/viceroy-ad-trace.toml" \ + --origin-url "$ORIGIN_URL" diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 714de510b..08dc5b5bc 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -38,6 +38,19 @@ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" +GENERATED_AD_TRACE_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy-ad-trace.toml" + +# Build the actual external Prebid bundle consumed by the isolated ad-trace +# fixture. The browser routes its first-party managed URL to this local asset; +# no public ad network is contacted. +echo "==> Building deterministic external Prebid fixture bundle..." +rm -rf "$REPO_ROOT/target/integration-test-artifacts/prebid" +mkdir -p "$REPO_ROOT/target/integration-test-artifacts/prebid" +npm ci --prefix crates/trusted-server-js/lib +npm run --prefix crates/trusted-server-js/lib build:prebid-external -- \ + --adapters=rubicon \ + --user-id-modules=sharedIdSystem \ + --out "$REPO_ROOT/target/integration-test-artifacts/prebid" # --- Build Docker images --- echo "==> Building WordPress test container..." @@ -50,6 +63,12 @@ docker build \ -t test-nextjs:latest \ crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ +echo "==> Building ad-trace test container..." +docker build \ + -f crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/Dockerfile \ + -t test-ad-trace:latest \ + . + # --- Install Playwright --- echo "==> Installing Playwright dependencies..." cd "$REPO_ROOT/$BROWSER_DIR" @@ -80,15 +99,22 @@ stop_matching_containers() { } cleanup() { + stop_matching_containers test-ad-trace:latest stop_matching_containers test-nextjs:latest stop_matching_containers test-wordpress:latest } trap cleanup EXIT # --- Run tests for each framework --- -for framework in nextjs wordpress; do +for framework in nextjs wordpress ad-trace; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test "$@" + if [ "$framework" = "ad-trace" ]; then + TEST_FRAMEWORK="$framework" VICEROY_CONFIG_PATH="$GENERATED_AD_TRACE_CONFIG_PATH" \ + npx playwright test "$@" + else + TEST_FRAMEWORK="$framework" VICEROY_CONFIG_PATH="$GENERATED_VICEROY_CONFIG_PATH" \ + npx playwright test "$@" + fi done echo "==> All browser tests passed." diff --git a/tinybird/datasources/auction_events_raw.datasource b/tinybird/datasources/auction_events_raw.datasource index d62f8ae5d..592158713 100644 --- a/tinybird/datasources/auction_events_raw.datasource +++ b/tinybird/datasources/auction_events_raw.datasource @@ -32,6 +32,7 @@ SCHEMA > `price_cpm` Nullable(Float64), `currency` LowCardinality(Nullable(String)), `is_win` Nullable(UInt8), + `bid_trace_id` Nullable(UUID), `ad_domain` Nullable(String), `ad_id` Nullable(String), `event_date` Date DEFAULT toDate(event_ts) diff --git a/tinybird/fixtures/auction_events_raw.ndjson b/tinybird/fixtures/auction_events_raw.ndjson index 078d0c533..10626e3ad 100644 --- a/tinybird/fixtures/auction_events_raw.ndjson +++ b/tinybird/fixtures/auction_events_raw.ndjson @@ -1,7 +1,7 @@ {"event_ts":"2026-06-23 12:00:00.000","event_kind":"summary","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":"completed","terminal_reason":null,"slot_count":2,"total_time_ms":120,"winning_bid_count":1,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:00:00.000","event_kind":"provider_call","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":"bidder","status":"success","provider_response_time_ms":80,"provider_bid_count":2,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:00:00.000","event_kind":"provider_call","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"aps","provider_role":"bidder","status":"nobid","provider_response_time_ms":95,"provider_bid_count":0,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} -{"event_ts":"2026-06-23 12:00:00.000","event_kind":"bid","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":"slot-1","slot_w":300,"slot_h":250,"media_type":"banner","seat":"kargo","price_cpm":1.25,"currency":"USD","is_win":1,"ad_domain":"advertiser.example","ad_id":"ad-1"} +{"event_ts":"2026-06-23 12:00:00.000","event_kind":"bid","auction_id":"550e8400-e29b-41d4-a716-446655440000","auction_source":"auction_api","publisher_domain":"test-publisher.example","page_path":"/article/:id","country":"US","region":"CA","is_mobile":0,"is_known_browser":1,"gdpr_applies":0,"consent_present":0,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":"slot-1","slot_w":300,"slot_h":250,"media_type":"banner","seat":"kargo","price_cpm":1.25,"currency":"USD","is_win":1,"bid_trace_id":"950e8400-e29b-41d4-a716-446655440000","ad_domain":"advertiser.example","ad_id":"ad-1"} {"event_ts":"2026-06-23 12:01:00.000","event_kind":"summary","auction_id":"650e8400-e29b-41d4-a716-446655440000","auction_source":"initial_navigation","publisher_domain":"test-publisher.example","page_path":"/sports","country":"US","region":"CA","is_mobile":1,"is_known_browser":1,"gdpr_applies":0,"consent_present":1,"terminal_status":"abandoned","terminal_reason":"pass_through_response","slot_count":1,"total_time_ms":35,"winning_bid_count":0,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:01:00.000","event_kind":"provider_call","auction_id":"650e8400-e29b-41d4-a716-446655440000","auction_source":"initial_navigation","publisher_domain":"test-publisher.example","page_path":"/sports","country":"US","region":"CA","is_mobile":1,"is_known_browser":1,"gdpr_applies":0,"consent_present":1,"terminal_status":null,"terminal_reason":null,"slot_count":null,"total_time_ms":null,"winning_bid_count":null,"provider":"prebid","provider_role":"bidder","status":"abandoned","provider_response_time_ms":35,"provider_bid_count":0,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} {"event_ts":"2026-06-23 12:02:00.000","event_kind":"summary","auction_id":"750e8400-e29b-41d4-a716-446655440000","auction_source":"spa_navigation","publisher_domain":"test-publisher.example","page_path":"/privacy","country":"DE","region":null,"is_mobile":2,"is_known_browser":2,"gdpr_applies":1,"consent_present":1,"terminal_status":"skipped","terminal_reason":"consent_denied","slot_count":1,"total_time_ms":0,"winning_bid_count":0,"provider":null,"provider_role":null,"status":null,"provider_response_time_ms":null,"provider_bid_count":null,"slot_id":null,"slot_w":null,"slot_h":null,"media_type":null,"seat":null,"price_cpm":null,"currency":null,"is_win":null,"ad_domain":null,"ad_id":null} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 7cd16133e..3d76102ca 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -59,6 +59,11 @@ enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] max_combined_payload_bytes = 10485760 +# Session-scoped auction-to-creative trace diagnostics. When enabled, visit a +# publisher page with `?ts_console=1` or `?ts_console=true` to open the console. +[integrations.ad_trace] +enabled = false + [integrations.testlight] enabled = false endpoint = "https://testlight.example.com/openrtb2/auction" From fd1cbd8de7f735f0bd629fe3c07bcf4b1a1f3fc7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 15:52:27 -0500 Subject: [PATCH 107/494] Clarify auction trace console evidence --- .../tests/ad-trace/auction-trace.spec.ts | 42 +++- .../lib/src/core/ad_trace.ts | 43 +++- .../trusted-server-js/lib/src/core/request.ts | 20 +- .../trusted-server-js/lib/src/core/types.ts | 6 + .../lib/src/integrations/ad_trace/index.ts | 14 +- .../lib/src/integrations/ad_trace/overlay.ts | 101 +++++--- .../src/integrations/ad_trace/presentation.ts | 174 +++++++++++++ .../lib/src/integrations/aps/render.ts | 13 +- .../lib/src/integrations/gpt/index.ts | 31 ++- .../lib/test/core/ad_trace.test.ts | 107 ++++++++ .../lib/test/core/request.test.ts | 66 +++++ .../test/integrations/ad_trace/index.test.ts | 27 ++ .../integrations/ad_trace/overlay.test.ts | 216 ++++++++++++++++ .../ad_trace/presentation.test.ts | 231 ++++++++++++++++++ .../lib/test/integrations/aps/render.test.ts | 9 +- .../lib/test/integrations/gpt/ad_init.test.ts | 121 +++++++++ 16 files changed, 1147 insertions(+), 74 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts diff --git a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts index 9c2935d07..075c30b57 100644 --- a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts @@ -118,7 +118,9 @@ test.describe("tester-only auction trace contract", () => { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL(runtimeUrl("/")); - expect(activation?.headers()["cache-control"]).toBe("private, no-store"); + expect(activation?.headers()["cache-control"]).toBe( + "private, no-store", + ); await expect .poll(() => page.evaluate( @@ -210,9 +212,13 @@ test.describe("tester-only auction trace contract", () => { const visibleText = tree.nodes .map((node) => node.name?.value || "") .join("\n"); - expect(visibleText).toContain("TS winner: won · definitive"); + expect(visibleText).toContain("Trusted Server selected a bid"); expect(visibleText).toContain( - "Creative: load_acknowledged · definitive", + "GAM selected the Trusted Server creative", + ); + expect(visibleText).toContain("Trusted Server creative load confirmed"); + expect(visibleText).not.toMatch( + /definitive|strong|probable|not_run|gam_only|TS winner|Prebid winner|#\d/, ); }); @@ -224,12 +230,14 @@ test.describe("tester-only auction trace contract", () => { const direct = document.createElement("div"); direct.id = "direct-api-slot"; document.body.appendChild(direct); - const ts = (window as Window & { - tsjs: { - addAdUnits(unit: unknown): void; - requestAds(): void; - }; - }).tsjs; + const ts = ( + window as Window & { + tsjs: { + addAdUnits(unit: unknown): void; + requestAds(): void; + }; + } + ).tsjs; ts.addAdUnits({ code: "direct-api-slot", mediaTypes: { banner: { sizes: [[300, 250]] } }, @@ -268,7 +276,7 @@ test.describe("tester-only auction trace contract", () => { }); }); - test("actual generated Prebid selects the traced TS bid before a probable GAM result", async ({ + test("actual generated Prebid selects the traced TS bid before an unattributed GAM render", async ({ page, }) => { await openTesterPage(page); @@ -352,6 +360,20 @@ test.describe("tester-only auction trace contract", () => { }) .toEqual({ prebid: "lost", gam: "client_prebid_candidate" }); + const session = await page.context().newCDPSession(page); + await expect + .poll(async () => { + const tree = (await session.send( + "Accessibility.getFullAXTree", + )) as { + nodes: Array<{ name?: { value?: string } }>; + }; + return tree.nodes + .map((node) => node.name?.value || "") + .join("\n"); + }) + .toContain("Prebid selected a client bid"); + await page.evaluate(() => { const win = window as Window & { adTraceFixture: { diff --git a/crates/trusted-server-js/lib/src/core/ad_trace.ts b/crates/trusted-server-js/lib/src/core/ad_trace.ts index aca037f5d..1ea51503f 100644 --- a/crates/trusted-server-js/lib/src/core/ad_trace.ts +++ b/crates/trusted-server-js/lib/src/core/ad_trace.ts @@ -39,7 +39,9 @@ const EVENT_KINDS = new Set([ 'gpt_slot_response_received', 'gpt_slot_render_ended', 'gpt_slot_onload', + 'gpt_impression_viewable', 'aps_display_bids_set', + 'aps_renderer_ready', 'pb_render_requested', 'pb_render_rejected', 'pb_render_served', @@ -135,12 +137,21 @@ function updateStage(target: Record, event: AdTr if (explicit && target.prebid.confidence !== 'definitive') target.prebid = explicit; break; case 'prebid_bid_won': - if (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') { + if ( + target.prebid.outcome === 'won' || + target.prebid.outcome === 'client_bid_won' || + target.prebid.outcome === 'lost' + ) { + // A Prebid win corroborates selection only. It is never creative-load + // evidence, including when the selected bid originated from Trusted Server. target.prebid = { ...target.prebid, reason: 'selected_targeting_with_bid_won', }; - if (target.gam.outcome === 'direct_or_unattributed') { + if ( + (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') && + target.gam.outcome === 'direct_or_unattributed' + ) { target.gam = { outcome: 'client_prebid_candidate', confidence: 'probable', @@ -202,6 +213,15 @@ function updateStage(target: Record, event: AdTr // APS setting display bids is a handoff only. GAM attribution remains // unobserved until a correlated non-empty GPT render arrives. break; + case 'aps_renderer_ready': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'aps_renderer_ready', + confidence: 'strong', + reason: event.reason ?? 'aps_renderer_ready', + }; + } + break; case 'gpt_slot_onload': if (target.creative.outcome === 'not_observed') target.creative = { @@ -232,7 +252,8 @@ function updateStage(target: Record, event: AdTr target.creative = { outcome: 'load_acknowledged', confidence: 'definitive', - reason: 'source_validated_load', + reason: + event.reason === 'direct_iframe_load' ? 'direct_iframe_load' : 'source_validated_load', }; if (event.reason !== 'direct_iframe_load') { target.gam = { @@ -268,6 +289,8 @@ function isRenderEvent(kind: AdTraceEventKind): boolean { return ( kind === 'gpt_request_started' || kind === 'gpt_slot_render_ended' || + kind === 'gpt_impression_viewable' || + kind === 'aps_renderer_ready' || kind === 'prebid_render_succeeded' || kind === 'prebid_render_failed' || kind === 'pb_render_requested' || @@ -375,6 +398,8 @@ export function createAdTraceStore( render.source = render.source === 'direct_auction' ? render.source : 'pb_render'; if (event.auctionTraceId) render.auctionTraceId = event.auctionTraceId; if (event.bidTraceId) render.bidTraceId = event.bidTraceId; + if (event.reason) render.reason = event.reason; + if (event.kind === 'gpt_impression_viewable') render.viewability = 'viewable'; render.updatedAt = timestamp; emitRender(render); }; @@ -497,6 +522,18 @@ export function createAdTraceStore( }; } +/** + * Map a public terminal auction outcome to an internal stage outcome. + * + * A completed auction without a final slot winner is a no-bid result. A + * completed auction with a winner is immediately followed by winner evidence, + * but remains distinct here so callers never erase failed or abandoned results. + */ +export function terminalSummaryStageOutcome(outcome: string, hasWinner = false): string { + if (outcome === 'completed') return hasWinner ? 'completed' : 'no_bid'; + return outcome; +} + export function isCanonicalTraceUuid(value: unknown): value is string { return safeUuid(value) !== undefined; } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index b7429d01c..47d31a013 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -1,6 +1,7 @@ // Request orchestration for tsjs: unified auction endpoint with iframe-based creative rendering. import { renderApsCreative } from '../integrations/aps/render'; +import { terminalSummaryStageOutcome } from './ad_trace'; import { buildAdRequest, sendAuction } from './auction'; import { collectContext } from './context'; import { log } from './log'; @@ -74,7 +75,7 @@ function recordRootSummary( slotId: owner.slotId, generation: owner.generation, auctionTraceId: summary.auctionTraceId, - outcome: summary.outcome === 'completed' && !hasWinner ? 'no_bid' : summary.outcome, + outcome: terminalSummaryStageOutcome(summary.outcome, hasWinner), confidence: 'definitive', reason: 'terminal_summary', }); @@ -170,7 +171,22 @@ export function requestAds( } if (bid.renderer) { if (!ownerIsCurrent(owner)) continue; - if (!renderApsCreative({ slotId, renderer: bid.renderer })) { + const started = renderApsCreative({ + slotId, + renderer: bid.renderer, + onReady: () => { + if (!ownerIsCurrent(owner) || !owner.generation) return; + window.tsjs?.recordAdTrace?.({ + kind: 'aps_renderer_ready', + slotId, + generation: owner.generation, + auctionTraceId: trace?.auctionTraceId, + bidTraceId: trace?.bidTraceId, + reason: 'direct_aps_renderer_ready', + }); + }, + }); + if (!started) { recordDirectRejection(owner, 'aps_render_rejected'); } else if (owner.generation) { window.tsjs?.recordAdTrace?.({ diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 0ba40fd5d..f54e45b44 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -126,7 +126,9 @@ export type AdTraceEventKind = | 'gpt_slot_response_received' | 'gpt_slot_render_ended' | 'gpt_slot_onload' + | 'gpt_impression_viewable' | 'aps_display_bids_set' + | 'aps_renderer_ready' | 'pb_render_requested' | 'pb_render_rejected' | 'pb_render_served' @@ -181,6 +183,10 @@ export interface RenderTraceSnapshot { outcome: RenderTraceOutcome; confidence: AdTraceConfidence; visibility: RenderTraceVisibility; + /** GPT reported this exact retained slot generation viewable. */ + viewability?: 'viewable'; + /** Bounded privacy-safe reason for the latest render evidence. */ + reason?: string; createdAt: number; updatedAt: number; } diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts index cf6d6d33c..27bf58418 100644 --- a/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts @@ -1,4 +1,9 @@ -import { createAdTraceStore, isBoundedTraceLabel, isCanonicalTraceUuid } from '../../core/ad_trace'; +import { + createAdTraceStore, + isBoundedTraceLabel, + isCanonicalTraceUuid, + terminalSummaryStageOutcome, +} from '../../core/ad_trace'; import type { AdTraceApi, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; import { installAdTraceOverlay } from './overlay'; @@ -79,12 +84,7 @@ export function installAdTrace(): boolean { kind: 'ts_auction_observed', slotId: slot.id, auctionTraceId: summary.auctionTraceId, - outcome: - summary.outcome === 'completed' || summary.outcome === 'no_bid' - ? 'no_bid' - : summary.outcome === 'skipped' - ? 'skipped' - : 'unresolved', + outcome: terminalSummaryStageOutcome(summary.outcome), confidence: 'definitive', reason: 'terminal_summary', }); diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts index fd08963d0..827f4e397 100644 --- a/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts @@ -1,10 +1,14 @@ import type { AdTraceApi, + AdTraceStage, + AdTraceStageName, RenderTraceSnapshot, RenderTraceVisibility, SlotTraceSnapshot, } from '../../core/types'; +import { presentTraceOverlay } from './presentation'; + const HOST_ID = 'ts-ad-trace-overlay'; const TRACE_ATTRIBUTES = [ 'data-ts-trace-seq', @@ -15,20 +19,29 @@ const TRACE_ATTRIBUTES = [ 'data-ts-trace-visibility', ] as const; -function stageLine(label: string, stage: { outcome: string; confidence: string }): string { - return `${label}: ${stage.outcome} · ${stage.confidence}`; +const EMPTY_STAGES: Record = { + trustedServer: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + prebid: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, +}; + +function stagesForRender(slot: SlotTraceSnapshot, render: RenderTraceSnapshot) { + return ( + slot.generations.find((generation) => generation.generation === render.generation)?.stages ?? + (slot.latestGeneration === render.generation ? slot.stages : undefined) + ); } -function badgeText(slot: SlotTraceSnapshot, render?: RenderTraceSnapshot): string { - return [ - render ? `#${render.sequence}: ${render.outcome} · ${render.visibility}` : undefined, - stageLine('TS winner', slot.stages.trustedServer), - stageLine('Prebid winner', slot.stages.prebid), - stageLine('GAM result', slot.stages.gam), - stageLine('Creative', slot.stages.creative), - ] - .filter(Boolean) - .join('\n'); +function latestRenderForSlot( + renders: readonly RenderTraceSnapshot[], + slot: SlotTraceSnapshot +): RenderTraceSnapshot | undefined { + for (let index = renders.length - 1; index >= 0; index -= 1) { + const render = renders[index]; + if (render.slotId === slot.slotId && render.generation === slot.latestGeneration) return render; + } + return undefined; } function removeTraceAttributes(element: HTMLElement): void { @@ -75,7 +88,10 @@ export function installAdTraceOverlay( .badge { position: fixed; z-index: 2147483647; max-width: 300px; padding: 6px 8px; border: 1px solid #72e0a6; border-radius: 4px; background: rgba(10,18,16,.94); color: #eefbf4; font: 11px/1.35 ui-monospace, monospace; white-space: pre; cursor: pointer; } - .badge.probable { border-color: #67a8ff; } + .badge.attributed { border-color: #72e0a6; } + .badge.unattributed { border-color: #67a8ff; } + .badge.empty { border-color: #ffd479; } + .badge.failed { border-color: #ff7b72; } .panel { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: 460px; max-height: 60vh; overflow: auto; padding: 10px; background: #0a1210; color: #eefbf4; border: 1px solid #72e0a6; font: 11px/1.4 ui-monospace, monospace; } @@ -136,31 +152,30 @@ export function installAdTraceOverlay( rows.replaceChildren(); const exported = api.export(); const slotById = new Map(exported.slots.map((slot) => [slot.slotId, slot])); - const latestBySlot = new Map(); - for (const item of exported.renders) latestBySlot.set(item.slotId, item); const nextObserved = new Set(); for (const item of [...exported.renders].reverse()) { + const slot = slotById.get(item.slotId); + const stages = slot && stagesForRender(slot, item); + // Render history outlives bounded generation-stage retention. Its own + // factual render outcome remains safe to show when the stages are gone. + const presentation = presentTraceOverlay(stages ?? EMPTY_STAGES, item); const row = document.createElement('div'); - row.className = 'row'; + row.className = `row ${presentation.className}`; const title = document.createElement('strong'); - title.textContent = `#${item.sequence} ${item.slotId} · ${item.source}`; + title.textContent = item.slotId; const summary = document.createElement('div'); - summary.textContent = `${item.outcome} · ${item.confidence} · ${item.visibility}`; + summary.textContent = presentation.primaryStatus ?? 'No trace result observed'; row.append(title, summary); row.addEventListener('click', () => { details.hidden = false; - details.textContent = JSON.stringify( - { render: item, stages: slotById.get(item.slotId)?.stages }, - null, - 2 - ); + details.textContent = JSON.stringify({ render: item, stages }, null, 2); }); rows.appendChild(row); } for (const [slotId, slot] of slotById) { - const item = latestBySlot.get(slotId); + const item = latestRenderForSlot(exported.renders, slot); const element = item ? window.tsjs?.getAdTraceElement?.(slotId, item.generation) : undefined; if (!element || !item) continue; const rect = element.getBoundingClientRect(); @@ -175,21 +190,29 @@ export function installAdTraceOverlay( nextObserved.add(element); if (!observedElements.has(element)) resizeObserver?.observe(element); stampRender(element, effectiveItem); - const badge = document.createElement('div'); - badge.className = `badge ${item.outcome === 'confirmed' ? '' : 'probable'}`; - badge.textContent = badgeText(slot, effectiveItem); - badge.style.left = `${Math.max(0, rect.left)}px`; - badge.style.top = `${Math.max(0, rect.top)}px`; - badge.addEventListener('click', () => { - panel.hidden = false; - details.hidden = false; - details.textContent = JSON.stringify( - { render: effectiveItem, stages: slot.stages }, - null, - 2 - ); - }); - badgeLayer.appendChild(badge); + const presentation = presentTraceOverlay(slot.stages, effectiveItem); + // A visibility calculation alone is not trace evidence. Do not place a + // marker over an ad until the trace has at least one observed fact. + const traceFacts = presentation.facts.filter( + (fact) => !fact.startsWith('Slot element currently ') + ); + if (traceFacts.length > 0) { + const badge = document.createElement('div'); + badge.className = `badge ${presentation.className}`; + badge.textContent = presentation.facts.join('\n'); + badge.style.left = `${Math.max(0, rect.left)}px`; + badge.style.top = `${Math.max(0, rect.top)}px`; + badge.addEventListener('click', () => { + panel.hidden = false; + details.hidden = false; + details.textContent = JSON.stringify( + { render: effectiveItem, stages: slot.stages }, + null, + 2 + ); + }); + badgeLayer.appendChild(badge); + } } for (const element of observedElements) { if (!nextObserved.has(element)) { diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts new file mode 100644 index 000000000..e443e5fa0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts @@ -0,0 +1,174 @@ +import type { + AdTraceStage, + AdTraceStageName, + RenderTraceSnapshot, + RenderTraceVisibility, +} from '../../core/types'; + +export type TraceOverlayPresentationClass = 'attributed' | 'unattributed' | 'empty' | 'failed'; + +export interface TraceOverlayPresentation { + /** Facts suitable for operator-facing badges and primary timeline rows. */ + facts: readonly string[]; + /** One concise description of the render result when render evidence exists. */ + renderStatus?: string; + /** Best observed fact for a compact primary timeline row. */ + primaryStatus?: string; + className: TraceOverlayPresentationClass; +} + +type TraceStages = Record; + +const STAGE_ORDER: readonly AdTraceStageName[] = ['trustedServer', 'prebid', 'gam', 'creative']; + +function stageFacts(name: AdTraceStageName, stage: AdTraceStage): readonly string[] { + if (stage.outcome === 'not_observed' || stage.outcome === 'not_run') return []; + + switch (name) { + case 'trustedServer': + switch (stage.outcome) { + case 'won': + return ['Trusted Server selected a bid']; + case 'no_bid': + return ['Trusted Server returned no bid']; + case 'skipped': + return ['Trusted Server auction skipped']; + case 'failed': + case 'abandoned': + return ['Trusted Server auction did not complete']; + default: + return []; + } + case 'prebid': + if (stage.outcome === 'won') { + return stage.reason === 'selected_targeting_with_bid_won' + ? ['Prebid selected the Trusted Server bid', 'Prebid reported the bid won'] + : ['Prebid selected the Trusted Server bid']; + } + if (stage.outcome === 'client_bid_won' || stage.outcome === 'lost') { + return stage.reason === 'selected_targeting_with_bid_won' + ? ['Prebid selected a client bid', 'Prebid reported the bid won'] + : ['Prebid selected a client bid']; + } + return []; + case 'gam': + switch (stage.outcome) { + case 'empty': + return ['GAM returned no ad']; + case 'backfill': + return ['GAM returned backfill']; + case 'trusted_server_won': + return ['GAM selected the Trusted Server creative']; + case 'trusted_server_candidate': + case 'client_prebid_candidate': + case 'direct_or_unattributed': + return ['GAM rendered an ad — source not attributed']; + default: + return []; + } + case 'creative': + switch (stage.outcome) { + case 'gpt_iframe_onload': + return ['GAM creative iframe loaded']; + case 'load_acknowledged': + return [ + stage.reason === 'direct_iframe_load' + ? 'Creative iframe load confirmed' + : 'Trusted Server creative load confirmed', + ]; + case 'prebid_render_succeeded': + return ['Prebid reported render succeeded']; + case 'render_failed': + return ['Prebid reported render failed']; + case 'aps_renderer_ready': + return ['APS renderer reported ready']; + case 'renderer_served': + if (stage.reason === 'direct_aps_renderer') { + return ['APS renderer started creative loading']; + } + if (stage.reason === 'aps_renderer') return ['APS renderer response sent']; + return ['Creative response sent to the renderer']; + case 'rejected': + return ['Trusted Server direct render rejected']; + default: + return []; + } + } +} + +function renderStatus(render?: RenderTraceSnapshot): string | undefined { + if (!render) return undefined; + + switch (render.outcome) { + case 'confirmed': + return render.source === 'direct_auction' + ? 'Creative iframe load confirmed' + : 'Trusted Server creative load confirmed'; + case 'served': + if (render.reason === 'direct_aps_renderer_ready') return 'APS renderer reported ready'; + if (render.reason === 'direct_aps_renderer') return 'APS renderer started creative loading'; + if (render.reason === 'aps_renderer') return 'APS renderer response sent'; + if (render.reason === 'direct_iframe_created') return 'Creative iframe created'; + return 'Creative response sent to the renderer'; + case 'gam_only': + return 'GAM rendered an ad — source not attributed'; + case 'empty': + return 'GAM returned no ad'; + case 'unresolved': + return undefined; + } +} + +function visibilityFact(visibility: RenderTraceVisibility | undefined): string | undefined { + if (visibility === 'visible') return 'Slot element currently visible'; + if (visibility === 'hidden') return 'Slot element currently hidden'; + return undefined; +} + +function presentationClass( + stages: TraceStages, + render?: RenderTraceSnapshot +): TraceOverlayPresentationClass { + if ( + stages.creative.outcome === 'load_acknowledged' || + stages.gam.outcome === 'trusted_server_won' || + render?.outcome === 'confirmed' + ) { + return 'attributed'; + } + if (stages.creative.outcome === 'render_failed') return 'failed'; + if (stages.gam.outcome === 'empty' || render?.outcome === 'empty') return 'empty'; + return 'unattributed'; +} + +/** + * Convert internal trace stages into factual operator-facing language. + * + * Raw outcomes, confidence, reasons, sequence IDs, and generation IDs remain + * available in technical details and exports; they are intentionally excluded + * from this presentation surface. + */ +export function presentTraceOverlay( + stages: TraceStages, + render?: RenderTraceSnapshot +): TraceOverlayPresentation { + const facts = new Set(); + for (const name of STAGE_ORDER) { + for (const fact of stageFacts(name, stages[name])) facts.add(fact); + } + const status = renderStatus(render); + if (status) facts.add(status); + const visibility = visibilityFact(render?.visibility); + if (visibility) facts.add(visibility); + if (render?.viewability === 'viewable') facts.add('Viewable impression observed'); + const factList = [...facts]; + const primaryStatus = + status ?? [...factList].reverse().find((fact) => !fact.startsWith('Slot element currently ')); + + return { + facts: factList, + renderStatus: status, + ...(primaryStatus ? { primaryStatus } : {}), + className: presentationClass(stages, render), + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index ff37e2cb4..fed5a063e 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -294,10 +294,16 @@ export function apsRendererUrl(pageOrigin = window.location.origin): string | un export interface RenderApsCreativeOptions { slotId: string; renderer: unknown; + /** Observational callback after the renderer's nonce-bound ready message. */ + onReady?: () => void; } /** Render APS through the static endpoint under an outer opaque-origin sandbox. */ -export function renderApsCreative({ slotId, renderer: input }: RenderApsCreativeOptions): boolean { +export function renderApsCreative({ + slotId, + renderer: input, + onReady, +}: RenderApsCreativeOptions): boolean { const renderer = validateApsRenderer(input); const rendererUrl = apsRendererUrl(); const nonce = createNonce(); @@ -346,6 +352,11 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative if (child !== iframe) child.remove(); } iframe.style.display = ''; + try { + onReady?.(); + } catch { + // Read-only diagnostics must never affect a successfully committed render. + } }; function receive(event: MessageEvent): void { if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 8ab89ed6e..1f9756601 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,3 +1,4 @@ +import { terminalSummaryStageOutcome } from '../../core/ad_trace'; import { log } from '../../core/log'; import type { AuctionSlot, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; import { @@ -935,7 +936,7 @@ export function captureAdTraceRequest( slotId, generation, auctionTraceId: serverSummary.auctionTraceId, - outcome: serverSummary.outcome === 'completed' ? 'no_bid' : serverSummary.outcome, + outcome: terminalSummaryStageOutcome(serverSummary.outcome), confidence: 'definitive', reason: 'terminal_summary', }); @@ -1012,7 +1013,12 @@ function candidateForSlot( candidate.slot === slot && !candidate.superseded && (includeTerminal || !candidate.terminal) && - monotonicNow() - candidate.createdAt <= 30_000 + // Terminal evidence such as iframe load and viewability can arrive well + // after the 30-second render-request window. Retain the exact terminal + // candidate until a replacement, navigation, or bounded eviction supersedes it. + (includeTerminal && candidate.terminal + ? true + : monotonicNow() - candidate.createdAt <= 30_000) ); if (candidates.length !== 1) { if (candidates.length > 1) { @@ -1047,9 +1053,18 @@ function installGptEvidenceListeners(service: GoogleTagPubAdsService): void { if (instrumented.__tsAdTraceListeners) return; instrumented.__tsAdTraceListeners = true; const record = - (kind: 'gpt_slot_requested' | 'gpt_slot_response_received' | 'gpt_slot_onload') => + ( + kind: + | 'gpt_slot_requested' + | 'gpt_slot_response_received' + | 'gpt_slot_onload' + | 'gpt_impression_viewable' + ) => (event: GptSlotEvent): void => { - const candidate = candidateForSlot(event.slot, kind === 'gpt_slot_onload'); + const candidate = candidateForSlot( + event.slot, + kind === 'gpt_slot_onload' || kind === 'gpt_impression_viewable' + ); if (!candidate) return; window.tsjs?.recordAdTrace?.({ kind, @@ -1061,6 +1076,7 @@ function installGptEvidenceListeners(service: GoogleTagPubAdsService): void { service.addEventListener('slotRequested', record('gpt_slot_requested')); service.addEventListener('slotResponseReceived', record('gpt_slot_response_received')); service.addEventListener('slotOnload', record('gpt_slot_onload')); + service.addEventListener('impressionViewable', record('gpt_impression_viewable')); service.addEventListener('slotRenderEnded', (event: GptSlotEvent) => { const candidate = candidateForSlot(event.slot); if (!candidate) return; @@ -1109,12 +1125,7 @@ export function installTsAdInit(): void { kind: 'ts_auction_observed', slotId: slot.id, auctionTraceId: summary.auctionTraceId, - outcome: - summary.outcome === 'completed' || summary.outcome === 'no_bid' - ? 'no_bid' - : summary.outcome === 'skipped' - ? 'skipped' - : 'unresolved', + outcome: terminalSummaryStageOutcome(summary.outcome), confidence: 'definitive', reason: 'terminal_summary', }); diff --git a/crates/trusted-server-js/lib/test/core/ad_trace.test.ts b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts index 0348e8dd7..2a16a58e7 100644 --- a/crates/trusted-server-js/lib/test/core/ad_trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts @@ -5,6 +5,7 @@ import { AD_TRACE_MAX_RENDERS, AD_TRACE_MAX_SLOTS, createAdTraceStore, + terminalSummaryStageOutcome, } from '../../src/core/ad_trace'; const BID_TRACE_ID = '550e8400-e29b-41d4-a716-446655440000'; @@ -153,6 +154,50 @@ describe('ad trace reducer', () => { ).toBe('no_bid'); }); + it('retains a Trusted Server Prebid selection when bidWon arrives without claiming creative load', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'prebid_targeting_selected', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + outcome: 'won', + confidence: 'definitive', + reason: 'selected_targeting', + }); + store.record({ + kind: 'prebid_bid_won', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + + expect(store.getSlot('slot-a')?.stages.prebid).toMatchObject({ + outcome: 'won', + reason: 'selected_targeting_with_bid_won', + }); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + }); + + it('preserves the direct iframe acknowledgement boundary without claiming GAM selection', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + reason: 'direct_iframe_load', + }); + + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'load_acknowledged', + reason: 'direct_iframe_load', + }); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('not_observed'); + }); + it('classifies overlap, client Prebid, APS, no-bid, and superseded states', () => { const store = createAdTraceStore(() => 1); const generation = store.nextGeneration('slot-a'); @@ -272,6 +317,60 @@ describe('ad trace reducer', () => { }); }); + it('keeps GPT viewability separate from element visibility and creative load', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: false, + }); + store.updateVisibility('slot-a', generation, 'hidden'); + store.record({ kind: 'gpt_impression_viewable', slotId: 'slot-a', generation }); + + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'gam_only', + visibility: 'hidden', + viewability: 'viewable', + }); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + }); + + it('distinguishes APS renderer start from the validated ready boundary', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'pb_render_served', + slotId: 'slot-a', + generation, + reason: 'direct_aps_renderer', + }); + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'renderer_served', + reason: 'direct_aps_renderer', + }); + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'served', + reason: 'direct_aps_renderer', + }); + + store.record({ + kind: 'aps_renderer_ready', + slotId: 'slot-a', + generation, + reason: 'direct_aps_renderer_ready', + }); + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'aps_renderer_ready', + reason: 'direct_aps_renderer_ready', + }); + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'served', + reason: 'direct_aps_renderer_ready', + }); + }); + it('dispatches a frozen privacy-safe render event', () => { const store = createAdTraceStore(() => 1); const observed: unknown[] = []; @@ -303,6 +402,14 @@ describe('ad trace reducer', () => { expect(store.getRenderTimeline()[0].slotId).toBe('render-1'); }); + it('preserves failed and abandoned terminal summaries while mapping completed no-winner to no bid', () => { + expect(terminalSummaryStageOutcome('completed')).toBe('no_bid'); + expect(terminalSummaryStageOutcome('completed', true)).toBe('completed'); + expect(terminalSummaryStageOutcome('failed')).toBe('failed'); + expect(terminalSummaryStageOutcome('abandoned')).toBe('abandoned'); + expect(terminalSummaryStageOutcome('skipped')).toBe('skipped'); + }); + it('rejects malformed runtime event kinds and confidence values', () => { const store = createAdTraceStore(() => 1); store.record({ kind: 'not-a-real-kind', slotId: 'slot-a' } as never); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index dc3cf9b68..2c39edb49 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -81,6 +81,11 @@ describe('request.requestAds', () => { width: apsBid.w, height: apsBid.h, }; + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValue(1), + } as any; (globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, @@ -117,6 +122,16 @@ describe('request.requestAds', () => { expect(iframe!.srcdoc).toBe(''); expect(iframe!.getAttribute('sandbox')).not.toContain('allow-same-origin'); expect(document.querySelector('#slot1 span')).not.toBeNull(); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'pb_render_served', + generation: 1, + reason: 'direct_aps_renderer', + }) + ); + expect(recordAdTrace).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'aps_renderer_ready' }) + ); const postMessage = vi.spyOn(iframe!.contentWindow!, 'postMessage'); iframe!.dispatchEvent(new Event('load')); @@ -131,6 +146,13 @@ describe('request.requestAds', () => { }) ); expect(document.querySelector('#slot1 span')).toBeNull(); + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'aps_renderer_ready', + generation: 1, + reason: 'direct_aps_renderer_ready', + }) + ); }); it('does not mutate the slot for an invalid APS descriptor', async () => { @@ -524,6 +546,50 @@ describe('request.requestAds', () => { ); }); + it.each(['failed', 'abandoned'] as const)( + 'preserves a direct auction %s terminal summary', + async (outcome) => { + const recordAdTrace = vi.fn(); + window.tsjs = { + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValue(1), + } as any; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ + ext: { + trusted_server: { + trace: { + version: 1, + auction_trace_id: '550e8400-e29b-41d4-a716-446655440000', + source: 'auction_api', + outcome, + }, + }, + }, + seatbid: [], + }), + }); + const { addAdUnits } = await import('../../src/core/registry'); + const { requestAds } = await import('../../src/core/request'); + document.body.innerHTML = '
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'ts_auction_observed', + outcome, + reason: 'terminal_summary', + }) + ); + } + ); + it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML (globalThis as any).fetch = vi.fn().mockResolvedValue({ diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts index a3eaa7d6f..f24a513b7 100644 --- a/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts @@ -33,6 +33,33 @@ describe('ad_trace integration gate', () => { expect(document.querySelectorAll('#ts-ad-trace-overlay')).toHaveLength(1); }); + it.each(['failed', 'abandoned'] as const)( + 'preserves a %s terminal summary when seeding a slot', + async (outcome) => { + window.__tsjs_adTraceActive = true; + window.tsjs = { + adSlots: [{ id: 'slot-a' }], + auctionTrace: { + version: 1, + auctionTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'initial_navigation', + outcome, + }, + } as any; + + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(true); + const generation = window.tsjs?.nextAdTraceGeneration?.('slot-a'); + expect( + window.tsjs?.adTrace?.getSlot('slot-a')?.generations[0]?.stages.trustedServer + ).toMatchObject({ + outcome, + reason: 'terminal_summary', + }); + expect(generation).toBeGreaterThan(0); + } + ); + it('does not accept the legacy tester cookie without bootstrap', async () => { document.cookie = 'ts-tester=true; Path=/'; const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts index 0a5aaa217..6e0770d21 100644 --- a/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts @@ -67,6 +67,15 @@ describe('ad trace overlay lifecycle', () => { } as any; const observe = vi.fn(); + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); vi.stubGlobal( 'ResizeObserver', class { @@ -89,6 +98,15 @@ describe('ad trace overlay lifecycle', () => { expect(rect).toHaveBeenCalledTimes(1); expect(observe).toHaveBeenCalledWith(element); expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'visible'); + const badge = shadow?.querySelector('.badge'); + const row = shadow?.querySelector('.row'); + expect(badge?.textContent).toBe( + 'Trusted Server selected a bid\nGAM rendered an ad — source not attributed\nSlot element currently visible' + ); + expect(row?.textContent).toContain('GAM rendered an ad — source not attributed'); + expect(`${badge?.textContent}\n${row?.textContent}`).not.toMatch( + /definitive|strong|probable|not_run|gam_only|TS winner|Prebid winner|#1/ + ); expect(element.getAttribute('data-ts-trace-seq')).toBe('1'); expect(element.getAttribute('data-ts-trace-outcome')).toBe('gam_only'); window.dispatchEvent(new Event('scroll')); @@ -107,4 +125,202 @@ describe('ad trace overlay lifecycle', () => { expect(element.hasAttribute('data-ts-trace-seq')).toBe(false); expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'disconnected'); }); + + it('does not add an empty badge when no operator-facing fact was observed', () => { + const element = document.createElement('div'); + document.body.appendChild(element); + window.tsjs = { + getAdTraceElement: () => element, + updateAdTraceVisibility: vi.fn(), + } as any; + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 300, + height: 250, + } as DOMRect); + const slot = { + slotId: 'slot-a', + latestGeneration: 1, + generations: [], + stages: { + trustedServer: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + prebid: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + }, + }; + const render = { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'unresolved', + confidence: 'none', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }; + installAdTraceOverlay( + { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => [render] as any, + export: () => + ({ + version: 1, + slots: [slot], + events: [], + renders: [render], + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }) as any, + }, + () => vi.fn() + ); + + expect(shadow?.querySelector('.badge')).toBeNull(); + expect(shadow?.querySelector('.row')?.textContent).toContain('No trace result observed'); + }); + + it('uses observed stage evidence when a render row has no render outcome', () => { + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + const stages = { + trustedServer: { outcome: 'won', confidence: 'definitive', reason: 'winner' }, + prebid: { outcome: 'won', confidence: 'definitive', reason: 'selected_targeting' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'render_failed', confidence: 'definitive', reason: 'failure' }, + }; + const slot = { + slotId: 'slot-a', + latestGeneration: 1, + generations: [{ generation: 1, stages }], + stages, + }; + const render = { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'unresolved', + confidence: 'none', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }; + installAdTraceOverlay( + { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => [render] as any, + export: () => + ({ + version: 1, + slots: [slot], + events: [], + renders: [render], + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }) as any, + }, + () => vi.fn() + ); + + const row = shadow?.querySelector('.row'); + expect(row?.textContent).toContain('Prebid reported render failed'); + expect(row?.textContent).not.toContain('No trace result observed'); + }); + + it('gives a retained render a factual status after its generation stages were evicted', () => { + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + const slot = { + slotId: 'slot-a', + latestGeneration: 2, + generations: [], + stages: { + trustedServer: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + prebid: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + }, + }; + const render = { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'gam_only', + confidence: 'probable', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }; + installAdTraceOverlay( + { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => [render] as any, + export: () => + ({ + version: 1, + slots: [slot], + events: [], + renders: [render], + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }) as any, + }, + () => vi.fn() + ); + + const row = shadow?.querySelector('.row'); + expect(row?.textContent).toContain('GAM rendered an ad — source not attributed'); + expect(row?.textContent).not.toMatch(/probable|gam_only|#1/); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts new file mode 100644 index 000000000..d8008d1cb --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; + +import { presentTraceOverlay } from '../../../src/integrations/ad_trace/presentation'; +import type { AdTraceStage, AdTraceStageName, RenderTraceSnapshot } from '../../../src/core/types'; + +function stage(outcome = 'not_observed', reason = 'none'): AdTraceStage { + return { outcome, confidence: 'none', reason }; +} + +function stages(overrides: Partial> = {}) { + return { + trustedServer: stage(), + prebid: stage(), + gam: stage(), + creative: stage(), + ...overrides, + }; +} + +function render( + outcome: RenderTraceSnapshot['outcome'], + overrides: Partial = {} +): RenderTraceSnapshot { + return { + sequence: 4, + slotId: 'slot-a', + generation: 2, + source: 'gpt', + outcome, + confidence: 'probable', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe('presentTraceOverlay', () => { + it.each([ + [ + 'server winner', + stages({ trustedServer: stage('won') }), + undefined, + ['Trusted Server selected a bid'], + ], + [ + 'server no bid', + stages({ trustedServer: stage('no_bid') }), + undefined, + ['Trusted Server returned no bid'], + ], + [ + 'server skip', + stages({ trustedServer: stage('skipped') }), + undefined, + ['Trusted Server auction skipped'], + ], + [ + 'server failure', + stages({ trustedServer: stage('failed') }), + undefined, + ['Trusted Server auction did not complete'], + ], + [ + 'server abandonment', + stages({ trustedServer: stage('abandoned') }), + undefined, + ['Trusted Server auction did not complete'], + ], + [ + 'traced Prebid selection', + stages({ prebid: stage('won') }), + undefined, + ['Prebid selected the Trusted Server bid'], + ], + [ + 'client Prebid selection', + stages({ prebid: stage('client_bid_won') }), + undefined, + ['Prebid selected a client bid'], + ], + [ + 'client Prebid selection recorded as lost server targeting', + stages({ prebid: stage('lost') }), + undefined, + ['Prebid selected a client bid'], + ], + [ + 'reported Prebid win', + stages({ prebid: stage('won', 'selected_targeting_with_bid_won') }), + undefined, + ['Prebid selected the Trusted Server bid', 'Prebid reported the bid won'], + ], + ['GAM empty', stages({ gam: stage('empty') }), undefined, ['GAM returned no ad']], + ['GAM backfill', stages({ gam: stage('backfill') }), undefined, ['GAM returned backfill']], + [ + 'unattributed GAM render', + stages({ gam: stage('direct_or_unattributed') }), + undefined, + ['GAM rendered an ad — source not attributed'], + ], + [ + 'selected Trusted Server GAM creative', + stages({ gam: stage('trusted_server_won') }), + undefined, + ['GAM selected the Trusted Server creative'], + ], + [ + 'GAM iframe load', + stages({ creative: stage('gpt_iframe_onload') }), + undefined, + ['GAM creative iframe loaded'], + ], + [ + 'creative acknowledgement', + stages({ creative: stage('load_acknowledged') }), + undefined, + ['Trusted Server creative load confirmed'], + ], + [ + 'direct iframe acknowledgement', + stages({ creative: stage('load_acknowledged', 'direct_iframe_load') }), + render('confirmed', { source: 'direct_auction' }), + ['Creative iframe load confirmed'], + ], + [ + 'Prebid render success', + stages({ creative: stage('prebid_render_succeeded') }), + undefined, + ['Prebid reported render succeeded'], + ], + [ + 'Prebid render failure', + stages({ creative: stage('render_failed') }), + undefined, + ['Prebid reported render failed'], + ], + [ + 'direct APS renderer started', + stages({ creative: stage('renderer_served', 'direct_aps_renderer') }), + render('served', { source: 'direct_auction', reason: 'direct_aps_renderer' }), + ['APS renderer started creative loading'], + ], + [ + 'direct APS renderer ready', + stages({ creative: stage('aps_renderer_ready', 'direct_aps_renderer_ready') }), + render('served', { source: 'direct_auction', reason: 'direct_aps_renderer_ready' }), + ['APS renderer reported ready'], + ], + [ + 'direct render rejection', + stages({ creative: stage('rejected') }), + undefined, + ['Trusted Server direct render rejected'], + ], + [ + 'current visibility', + stages(), + render('unresolved', { visibility: 'visible' }), + ['Slot element currently visible'], + ], + [ + 'viewable impression independent of live visibility', + stages({ creative: stage('gpt_iframe_onload') }), + render('gam_only', { visibility: 'hidden', viewability: 'viewable' }), + [ + 'GAM creative iframe loaded', + 'GAM rendered an ad — source not attributed', + 'Slot element currently hidden', + 'Viewable impression observed', + ], + ], + ])('%s uses factual operator language', (_name, input, snapshot, expected) => { + expect(presentTraceOverlay(input, snapshot).facts).toEqual(expected); + }); + + it('hides unobserved and inapplicable stages without leaking internal vocabulary', () => { + const presentation = presentTraceOverlay( + stages({ + trustedServer: stage('unresolved'), + prebid: stage('not_run', 'direct'), + gam: stage('not_observed'), + creative: stage('not_observed'), + }), + render('unresolved', { visibility: 'unknown' }) + ); + + expect(presentation.facts).toEqual([]); + expect(presentation.renderStatus).toBeUndefined(); + expect(JSON.stringify(presentation)).not.toMatch( + /definitive|strong|probable|not_run|not_observed|unresolved|gam_only|client_bid_won/ + ); + }); + + it.each([ + ['attributed', stages({ creative: stage('load_acknowledged') }), undefined], + ['empty', stages({ gam: stage('empty') }), undefined], + ['failed', stages({ creative: stage('render_failed') }), undefined], + ['unattributed', stages({ gam: stage('trusted_server_candidate') }), render('gam_only')], + ] as const)('uses an evidence-based %s presentation class', (expected, input, snapshot) => { + expect(presentTraceOverlay(input, snapshot).className).toBe(expected); + }); + + it.each([ + [ + 'confirmed Trusted Server creative', + render('confirmed'), + 'Trusted Server creative load confirmed', + ], + [ + 'confirmed direct creative', + render('confirmed', { source: 'direct_auction' }), + 'Creative iframe load confirmed', + ], + ['served renderer', render('served'), 'Creative response sent to the renderer'], + ['unattributed GAM render', render('gam_only'), 'GAM rendered an ad — source not attributed'], + ['empty GAM response', render('empty'), 'GAM returned no ad'], + ] as const)('renders %s as a concise factual row status', (_name, snapshot, expected) => { + expect(presentTraceOverlay(stages(), snapshot).renderStatus).toBe(expected); + }); + + it('falls back to the strongest observed stage fact for a primary row', () => { + const presentation = presentTraceOverlay( + stages({ creative: stage('render_failed') }), + render('unresolved') + ); + + expect(presentation.renderStatus).toBeUndefined(); + expect(presentation.primaryStatus).toBe('Prebid reported render failed'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts index e7978e1cb..b7cf2e2f0 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts @@ -182,8 +182,11 @@ describe('direct APS rendering', () => { document.body.innerHTML = ''; }); - it('loads the static route with a fragment-bound 128-bit nonce and opaque sandbox', () => { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); + it('loads the static route with a fragment-bound 128-bit nonce and reports only validated readiness', () => { + const onReady = vi.fn(); + expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor(), onReady })).toBe( + true + ); const slot = document.getElementById('fictional-slot')!; const iframe = slot.querySelector('iframe')!; @@ -219,6 +222,7 @@ describe('direct APS rendering', () => { }) ); expect(slot.querySelector('span')).not.toBeNull(); + expect(onReady).not.toHaveBeenCalled(); window.dispatchEvent( new MessageEvent('message', { @@ -228,6 +232,7 @@ describe('direct APS rendering', () => { ); expect(slot.querySelector('span')).toBeNull(); expect(iframe.style.display).toBe(''); + expect(onReady).toHaveBeenCalledTimes(1); }); it('leaves existing slot content intact when validation or loading fails', () => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 7232668ab..222ba95e6 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -155,6 +155,127 @@ describe('installTsAdInit', () => { fetchSpy.mockRestore(); }); + it.each(['failed', 'abandoned'] as const)( + 'preserves a %s terminal summary when adInit has no traced bid', + async (outcome) => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const recordAdTrace = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + auctionTrace: { + version: 1, + auctionTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'initial_navigation', + outcome, + }, + recordAdTrace, + } as any; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'ts_auction_observed', + outcome, + reason: 'terminal_summary', + }) + ); + } + ); + + it('records late GPT viewability on the exact terminal request generation', async () => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const listeners: Record void>> = {}; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn((key: string) => { + if (key === 'hb_adid') return ['client-ad-id']; + if (key === 'hb_bidder') return ['client-bidder']; + return []; + }), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn((event: string, fn: (value: SlotRenderEvent) => void) => { + (listeners[event] ??= []).push(fn); + }), + refresh: vi.fn(), + }; + const recordAdTrace = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValue(1), + } as any; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + now = 1; + listeners.slotRenderEnded?.forEach((listener) => listener({ isEmpty: false, slot: mockSlot })); + now = 60_001; + listeners.impressionViewable?.forEach((listener) => + listener({ isEmpty: false, slot: mockSlot }) + ); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'gpt_impression_viewable', + slotId: 'atf_sidebar_ad', + generation: 1, + }) + ); + }); + it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), From ea6aadfb41ea1aa86505bf15414feb4b883de32e Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 15:52:27 -0500 Subject: [PATCH 108/494] Clarify auction trace console evidence --- .../tests/ad-trace/auction-trace.spec.ts | 42 +++- .../lib/src/core/ad_trace.ts | 43 +++- .../trusted-server-js/lib/src/core/request.ts | 3 +- .../trusted-server-js/lib/src/core/types.ts | 6 + .../lib/src/integrations/ad_trace/index.ts | 14 +- .../lib/src/integrations/ad_trace/overlay.ts | 101 +++++--- .../src/integrations/ad_trace/presentation.ts | 174 +++++++++++++ .../lib/src/integrations/gpt/index.ts | 31 ++- .../lib/test/core/ad_trace.test.ts | 107 ++++++++ .../test/integrations/ad_trace/index.test.ts | 27 ++ .../integrations/ad_trace/overlay.test.ts | 216 ++++++++++++++++ .../ad_trace/presentation.test.ts | 231 ++++++++++++++++++ .../lib/test/integrations/gpt/ad_init.test.ts | 121 +++++++++ 13 files changed, 1046 insertions(+), 70 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts diff --git a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts index 9c2935d07..075c30b57 100644 --- a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts @@ -118,7 +118,9 @@ test.describe("tester-only auction trace contract", () => { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL(runtimeUrl("/")); - expect(activation?.headers()["cache-control"]).toBe("private, no-store"); + expect(activation?.headers()["cache-control"]).toBe( + "private, no-store", + ); await expect .poll(() => page.evaluate( @@ -210,9 +212,13 @@ test.describe("tester-only auction trace contract", () => { const visibleText = tree.nodes .map((node) => node.name?.value || "") .join("\n"); - expect(visibleText).toContain("TS winner: won · definitive"); + expect(visibleText).toContain("Trusted Server selected a bid"); expect(visibleText).toContain( - "Creative: load_acknowledged · definitive", + "GAM selected the Trusted Server creative", + ); + expect(visibleText).toContain("Trusted Server creative load confirmed"); + expect(visibleText).not.toMatch( + /definitive|strong|probable|not_run|gam_only|TS winner|Prebid winner|#\d/, ); }); @@ -224,12 +230,14 @@ test.describe("tester-only auction trace contract", () => { const direct = document.createElement("div"); direct.id = "direct-api-slot"; document.body.appendChild(direct); - const ts = (window as Window & { - tsjs: { - addAdUnits(unit: unknown): void; - requestAds(): void; - }; - }).tsjs; + const ts = ( + window as Window & { + tsjs: { + addAdUnits(unit: unknown): void; + requestAds(): void; + }; + } + ).tsjs; ts.addAdUnits({ code: "direct-api-slot", mediaTypes: { banner: { sizes: [[300, 250]] } }, @@ -268,7 +276,7 @@ test.describe("tester-only auction trace contract", () => { }); }); - test("actual generated Prebid selects the traced TS bid before a probable GAM result", async ({ + test("actual generated Prebid selects the traced TS bid before an unattributed GAM render", async ({ page, }) => { await openTesterPage(page); @@ -352,6 +360,20 @@ test.describe("tester-only auction trace contract", () => { }) .toEqual({ prebid: "lost", gam: "client_prebid_candidate" }); + const session = await page.context().newCDPSession(page); + await expect + .poll(async () => { + const tree = (await session.send( + "Accessibility.getFullAXTree", + )) as { + nodes: Array<{ name?: { value?: string } }>; + }; + return tree.nodes + .map((node) => node.name?.value || "") + .join("\n"); + }) + .toContain("Prebid selected a client bid"); + await page.evaluate(() => { const win = window as Window & { adTraceFixture: { diff --git a/crates/trusted-server-js/lib/src/core/ad_trace.ts b/crates/trusted-server-js/lib/src/core/ad_trace.ts index aca037f5d..1ea51503f 100644 --- a/crates/trusted-server-js/lib/src/core/ad_trace.ts +++ b/crates/trusted-server-js/lib/src/core/ad_trace.ts @@ -39,7 +39,9 @@ const EVENT_KINDS = new Set([ 'gpt_slot_response_received', 'gpt_slot_render_ended', 'gpt_slot_onload', + 'gpt_impression_viewable', 'aps_display_bids_set', + 'aps_renderer_ready', 'pb_render_requested', 'pb_render_rejected', 'pb_render_served', @@ -135,12 +137,21 @@ function updateStage(target: Record, event: AdTr if (explicit && target.prebid.confidence !== 'definitive') target.prebid = explicit; break; case 'prebid_bid_won': - if (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') { + if ( + target.prebid.outcome === 'won' || + target.prebid.outcome === 'client_bid_won' || + target.prebid.outcome === 'lost' + ) { + // A Prebid win corroborates selection only. It is never creative-load + // evidence, including when the selected bid originated from Trusted Server. target.prebid = { ...target.prebid, reason: 'selected_targeting_with_bid_won', }; - if (target.gam.outcome === 'direct_or_unattributed') { + if ( + (target.prebid.outcome === 'client_bid_won' || target.prebid.outcome === 'lost') && + target.gam.outcome === 'direct_or_unattributed' + ) { target.gam = { outcome: 'client_prebid_candidate', confidence: 'probable', @@ -202,6 +213,15 @@ function updateStage(target: Record, event: AdTr // APS setting display bids is a handoff only. GAM attribution remains // unobserved until a correlated non-empty GPT render arrives. break; + case 'aps_renderer_ready': + if (target.creative.confidence !== 'definitive') { + target.creative = { + outcome: 'aps_renderer_ready', + confidence: 'strong', + reason: event.reason ?? 'aps_renderer_ready', + }; + } + break; case 'gpt_slot_onload': if (target.creative.outcome === 'not_observed') target.creative = { @@ -232,7 +252,8 @@ function updateStage(target: Record, event: AdTr target.creative = { outcome: 'load_acknowledged', confidence: 'definitive', - reason: 'source_validated_load', + reason: + event.reason === 'direct_iframe_load' ? 'direct_iframe_load' : 'source_validated_load', }; if (event.reason !== 'direct_iframe_load') { target.gam = { @@ -268,6 +289,8 @@ function isRenderEvent(kind: AdTraceEventKind): boolean { return ( kind === 'gpt_request_started' || kind === 'gpt_slot_render_ended' || + kind === 'gpt_impression_viewable' || + kind === 'aps_renderer_ready' || kind === 'prebid_render_succeeded' || kind === 'prebid_render_failed' || kind === 'pb_render_requested' || @@ -375,6 +398,8 @@ export function createAdTraceStore( render.source = render.source === 'direct_auction' ? render.source : 'pb_render'; if (event.auctionTraceId) render.auctionTraceId = event.auctionTraceId; if (event.bidTraceId) render.bidTraceId = event.bidTraceId; + if (event.reason) render.reason = event.reason; + if (event.kind === 'gpt_impression_viewable') render.viewability = 'viewable'; render.updatedAt = timestamp; emitRender(render); }; @@ -497,6 +522,18 @@ export function createAdTraceStore( }; } +/** + * Map a public terminal auction outcome to an internal stage outcome. + * + * A completed auction without a final slot winner is a no-bid result. A + * completed auction with a winner is immediately followed by winner evidence, + * but remains distinct here so callers never erase failed or abandoned results. + */ +export function terminalSummaryStageOutcome(outcome: string, hasWinner = false): string { + if (outcome === 'completed') return hasWinner ? 'completed' : 'no_bid'; + return outcome; +} + export function isCanonicalTraceUuid(value: unknown): value is string { return safeUuid(value) !== undefined; } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index 40b41d524..a109b03fd 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -3,6 +3,7 @@ import { log } from './log'; import { collectContext } from './context'; import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; +import { terminalSummaryStageOutcome } from './ad_trace'; import { buildAdRequest, sendAuction } from './auction'; import type { AuctionTraceSummary, TrustedServerBidTrace } from './types'; @@ -72,7 +73,7 @@ function recordRootSummary( slotId: owner.slotId, generation: owner.generation, auctionTraceId: summary.auctionTraceId, - outcome: summary.outcome === 'completed' && !hasWinner ? 'no_bid' : summary.outcome, + outcome: terminalSummaryStageOutcome(summary.outcome, hasWinner), confidence: 'definitive', reason: 'terminal_summary', }); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 615f174ef..14a9da358 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -94,7 +94,9 @@ export type AdTraceEventKind = | 'gpt_slot_response_received' | 'gpt_slot_render_ended' | 'gpt_slot_onload' + | 'gpt_impression_viewable' | 'aps_display_bids_set' + | 'aps_renderer_ready' | 'pb_render_requested' | 'pb_render_rejected' | 'pb_render_served' @@ -149,6 +151,10 @@ export interface RenderTraceSnapshot { outcome: RenderTraceOutcome; confidence: AdTraceConfidence; visibility: RenderTraceVisibility; + /** GPT reported this exact retained slot generation viewable. */ + viewability?: 'viewable'; + /** Bounded privacy-safe reason for the latest render evidence. */ + reason?: string; createdAt: number; updatedAt: number; } diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts index cf6d6d33c..27bf58418 100644 --- a/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/index.ts @@ -1,4 +1,9 @@ -import { createAdTraceStore, isBoundedTraceLabel, isCanonicalTraceUuid } from '../../core/ad_trace'; +import { + createAdTraceStore, + isBoundedTraceLabel, + isCanonicalTraceUuid, + terminalSummaryStageOutcome, +} from '../../core/ad_trace'; import type { AdTraceApi, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; import { installAdTraceOverlay } from './overlay'; @@ -79,12 +84,7 @@ export function installAdTrace(): boolean { kind: 'ts_auction_observed', slotId: slot.id, auctionTraceId: summary.auctionTraceId, - outcome: - summary.outcome === 'completed' || summary.outcome === 'no_bid' - ? 'no_bid' - : summary.outcome === 'skipped' - ? 'skipped' - : 'unresolved', + outcome: terminalSummaryStageOutcome(summary.outcome), confidence: 'definitive', reason: 'terminal_summary', }); diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts index fd08963d0..827f4e397 100644 --- a/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/overlay.ts @@ -1,10 +1,14 @@ import type { AdTraceApi, + AdTraceStage, + AdTraceStageName, RenderTraceSnapshot, RenderTraceVisibility, SlotTraceSnapshot, } from '../../core/types'; +import { presentTraceOverlay } from './presentation'; + const HOST_ID = 'ts-ad-trace-overlay'; const TRACE_ATTRIBUTES = [ 'data-ts-trace-seq', @@ -15,20 +19,29 @@ const TRACE_ATTRIBUTES = [ 'data-ts-trace-visibility', ] as const; -function stageLine(label: string, stage: { outcome: string; confidence: string }): string { - return `${label}: ${stage.outcome} · ${stage.confidence}`; +const EMPTY_STAGES: Record = { + trustedServer: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + prebid: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, +}; + +function stagesForRender(slot: SlotTraceSnapshot, render: RenderTraceSnapshot) { + return ( + slot.generations.find((generation) => generation.generation === render.generation)?.stages ?? + (slot.latestGeneration === render.generation ? slot.stages : undefined) + ); } -function badgeText(slot: SlotTraceSnapshot, render?: RenderTraceSnapshot): string { - return [ - render ? `#${render.sequence}: ${render.outcome} · ${render.visibility}` : undefined, - stageLine('TS winner', slot.stages.trustedServer), - stageLine('Prebid winner', slot.stages.prebid), - stageLine('GAM result', slot.stages.gam), - stageLine('Creative', slot.stages.creative), - ] - .filter(Boolean) - .join('\n'); +function latestRenderForSlot( + renders: readonly RenderTraceSnapshot[], + slot: SlotTraceSnapshot +): RenderTraceSnapshot | undefined { + for (let index = renders.length - 1; index >= 0; index -= 1) { + const render = renders[index]; + if (render.slotId === slot.slotId && render.generation === slot.latestGeneration) return render; + } + return undefined; } function removeTraceAttributes(element: HTMLElement): void { @@ -75,7 +88,10 @@ export function installAdTraceOverlay( .badge { position: fixed; z-index: 2147483647; max-width: 300px; padding: 6px 8px; border: 1px solid #72e0a6; border-radius: 4px; background: rgba(10,18,16,.94); color: #eefbf4; font: 11px/1.35 ui-monospace, monospace; white-space: pre; cursor: pointer; } - .badge.probable { border-color: #67a8ff; } + .badge.attributed { border-color: #72e0a6; } + .badge.unattributed { border-color: #67a8ff; } + .badge.empty { border-color: #ffd479; } + .badge.failed { border-color: #ff7b72; } .panel { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: 460px; max-height: 60vh; overflow: auto; padding: 10px; background: #0a1210; color: #eefbf4; border: 1px solid #72e0a6; font: 11px/1.4 ui-monospace, monospace; } @@ -136,31 +152,30 @@ export function installAdTraceOverlay( rows.replaceChildren(); const exported = api.export(); const slotById = new Map(exported.slots.map((slot) => [slot.slotId, slot])); - const latestBySlot = new Map(); - for (const item of exported.renders) latestBySlot.set(item.slotId, item); const nextObserved = new Set(); for (const item of [...exported.renders].reverse()) { + const slot = slotById.get(item.slotId); + const stages = slot && stagesForRender(slot, item); + // Render history outlives bounded generation-stage retention. Its own + // factual render outcome remains safe to show when the stages are gone. + const presentation = presentTraceOverlay(stages ?? EMPTY_STAGES, item); const row = document.createElement('div'); - row.className = 'row'; + row.className = `row ${presentation.className}`; const title = document.createElement('strong'); - title.textContent = `#${item.sequence} ${item.slotId} · ${item.source}`; + title.textContent = item.slotId; const summary = document.createElement('div'); - summary.textContent = `${item.outcome} · ${item.confidence} · ${item.visibility}`; + summary.textContent = presentation.primaryStatus ?? 'No trace result observed'; row.append(title, summary); row.addEventListener('click', () => { details.hidden = false; - details.textContent = JSON.stringify( - { render: item, stages: slotById.get(item.slotId)?.stages }, - null, - 2 - ); + details.textContent = JSON.stringify({ render: item, stages }, null, 2); }); rows.appendChild(row); } for (const [slotId, slot] of slotById) { - const item = latestBySlot.get(slotId); + const item = latestRenderForSlot(exported.renders, slot); const element = item ? window.tsjs?.getAdTraceElement?.(slotId, item.generation) : undefined; if (!element || !item) continue; const rect = element.getBoundingClientRect(); @@ -175,21 +190,29 @@ export function installAdTraceOverlay( nextObserved.add(element); if (!observedElements.has(element)) resizeObserver?.observe(element); stampRender(element, effectiveItem); - const badge = document.createElement('div'); - badge.className = `badge ${item.outcome === 'confirmed' ? '' : 'probable'}`; - badge.textContent = badgeText(slot, effectiveItem); - badge.style.left = `${Math.max(0, rect.left)}px`; - badge.style.top = `${Math.max(0, rect.top)}px`; - badge.addEventListener('click', () => { - panel.hidden = false; - details.hidden = false; - details.textContent = JSON.stringify( - { render: effectiveItem, stages: slot.stages }, - null, - 2 - ); - }); - badgeLayer.appendChild(badge); + const presentation = presentTraceOverlay(slot.stages, effectiveItem); + // A visibility calculation alone is not trace evidence. Do not place a + // marker over an ad until the trace has at least one observed fact. + const traceFacts = presentation.facts.filter( + (fact) => !fact.startsWith('Slot element currently ') + ); + if (traceFacts.length > 0) { + const badge = document.createElement('div'); + badge.className = `badge ${presentation.className}`; + badge.textContent = presentation.facts.join('\n'); + badge.style.left = `${Math.max(0, rect.left)}px`; + badge.style.top = `${Math.max(0, rect.top)}px`; + badge.addEventListener('click', () => { + panel.hidden = false; + details.hidden = false; + details.textContent = JSON.stringify( + { render: effectiveItem, stages: slot.stages }, + null, + 2 + ); + }); + badgeLayer.appendChild(badge); + } } for (const element of observedElements) { if (!nextObserved.has(element)) { diff --git a/crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts b/crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts new file mode 100644 index 000000000..e443e5fa0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ad_trace/presentation.ts @@ -0,0 +1,174 @@ +import type { + AdTraceStage, + AdTraceStageName, + RenderTraceSnapshot, + RenderTraceVisibility, +} from '../../core/types'; + +export type TraceOverlayPresentationClass = 'attributed' | 'unattributed' | 'empty' | 'failed'; + +export interface TraceOverlayPresentation { + /** Facts suitable for operator-facing badges and primary timeline rows. */ + facts: readonly string[]; + /** One concise description of the render result when render evidence exists. */ + renderStatus?: string; + /** Best observed fact for a compact primary timeline row. */ + primaryStatus?: string; + className: TraceOverlayPresentationClass; +} + +type TraceStages = Record; + +const STAGE_ORDER: readonly AdTraceStageName[] = ['trustedServer', 'prebid', 'gam', 'creative']; + +function stageFacts(name: AdTraceStageName, stage: AdTraceStage): readonly string[] { + if (stage.outcome === 'not_observed' || stage.outcome === 'not_run') return []; + + switch (name) { + case 'trustedServer': + switch (stage.outcome) { + case 'won': + return ['Trusted Server selected a bid']; + case 'no_bid': + return ['Trusted Server returned no bid']; + case 'skipped': + return ['Trusted Server auction skipped']; + case 'failed': + case 'abandoned': + return ['Trusted Server auction did not complete']; + default: + return []; + } + case 'prebid': + if (stage.outcome === 'won') { + return stage.reason === 'selected_targeting_with_bid_won' + ? ['Prebid selected the Trusted Server bid', 'Prebid reported the bid won'] + : ['Prebid selected the Trusted Server bid']; + } + if (stage.outcome === 'client_bid_won' || stage.outcome === 'lost') { + return stage.reason === 'selected_targeting_with_bid_won' + ? ['Prebid selected a client bid', 'Prebid reported the bid won'] + : ['Prebid selected a client bid']; + } + return []; + case 'gam': + switch (stage.outcome) { + case 'empty': + return ['GAM returned no ad']; + case 'backfill': + return ['GAM returned backfill']; + case 'trusted_server_won': + return ['GAM selected the Trusted Server creative']; + case 'trusted_server_candidate': + case 'client_prebid_candidate': + case 'direct_or_unattributed': + return ['GAM rendered an ad — source not attributed']; + default: + return []; + } + case 'creative': + switch (stage.outcome) { + case 'gpt_iframe_onload': + return ['GAM creative iframe loaded']; + case 'load_acknowledged': + return [ + stage.reason === 'direct_iframe_load' + ? 'Creative iframe load confirmed' + : 'Trusted Server creative load confirmed', + ]; + case 'prebid_render_succeeded': + return ['Prebid reported render succeeded']; + case 'render_failed': + return ['Prebid reported render failed']; + case 'aps_renderer_ready': + return ['APS renderer reported ready']; + case 'renderer_served': + if (stage.reason === 'direct_aps_renderer') { + return ['APS renderer started creative loading']; + } + if (stage.reason === 'aps_renderer') return ['APS renderer response sent']; + return ['Creative response sent to the renderer']; + case 'rejected': + return ['Trusted Server direct render rejected']; + default: + return []; + } + } +} + +function renderStatus(render?: RenderTraceSnapshot): string | undefined { + if (!render) return undefined; + + switch (render.outcome) { + case 'confirmed': + return render.source === 'direct_auction' + ? 'Creative iframe load confirmed' + : 'Trusted Server creative load confirmed'; + case 'served': + if (render.reason === 'direct_aps_renderer_ready') return 'APS renderer reported ready'; + if (render.reason === 'direct_aps_renderer') return 'APS renderer started creative loading'; + if (render.reason === 'aps_renderer') return 'APS renderer response sent'; + if (render.reason === 'direct_iframe_created') return 'Creative iframe created'; + return 'Creative response sent to the renderer'; + case 'gam_only': + return 'GAM rendered an ad — source not attributed'; + case 'empty': + return 'GAM returned no ad'; + case 'unresolved': + return undefined; + } +} + +function visibilityFact(visibility: RenderTraceVisibility | undefined): string | undefined { + if (visibility === 'visible') return 'Slot element currently visible'; + if (visibility === 'hidden') return 'Slot element currently hidden'; + return undefined; +} + +function presentationClass( + stages: TraceStages, + render?: RenderTraceSnapshot +): TraceOverlayPresentationClass { + if ( + stages.creative.outcome === 'load_acknowledged' || + stages.gam.outcome === 'trusted_server_won' || + render?.outcome === 'confirmed' + ) { + return 'attributed'; + } + if (stages.creative.outcome === 'render_failed') return 'failed'; + if (stages.gam.outcome === 'empty' || render?.outcome === 'empty') return 'empty'; + return 'unattributed'; +} + +/** + * Convert internal trace stages into factual operator-facing language. + * + * Raw outcomes, confidence, reasons, sequence IDs, and generation IDs remain + * available in technical details and exports; they are intentionally excluded + * from this presentation surface. + */ +export function presentTraceOverlay( + stages: TraceStages, + render?: RenderTraceSnapshot +): TraceOverlayPresentation { + const facts = new Set(); + for (const name of STAGE_ORDER) { + for (const fact of stageFacts(name, stages[name])) facts.add(fact); + } + const status = renderStatus(render); + if (status) facts.add(status); + const visibility = visibilityFact(render?.visibility); + if (visibility) facts.add(visibility); + if (render?.viewability === 'viewable') facts.add('Viewable impression observed'); + const factList = [...facts]; + const primaryStatus = + status ?? [...factList].reverse().find((fact) => !fact.startsWith('Slot element currently ')); + + return { + facts: factList, + renderStatus: status, + ...(primaryStatus ? { primaryStatus } : {}), + className: presentationClass(stages, render), + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 63aca2833..dbe55d233 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,3 +1,4 @@ +import { terminalSummaryStageOutcome } from '../../core/ad_trace'; import { log } from '../../core/log'; import type { AuctionSlot, AuctionBidData, AuctionTraceSummary, TsjsApi } from '../../core/types'; @@ -886,7 +887,7 @@ export function captureAdTraceRequest( slotId, generation, auctionTraceId: serverSummary.auctionTraceId, - outcome: serverSummary.outcome === 'completed' ? 'no_bid' : serverSummary.outcome, + outcome: terminalSummaryStageOutcome(serverSummary.outcome), confidence: 'definitive', reason: 'terminal_summary', }); @@ -963,7 +964,12 @@ function candidateForSlot( candidate.slot === slot && !candidate.superseded && (includeTerminal || !candidate.terminal) && - monotonicNow() - candidate.createdAt <= 30_000 + // Terminal evidence such as iframe load and viewability can arrive well + // after the 30-second render-request window. Retain the exact terminal + // candidate until a replacement, navigation, or bounded eviction supersedes it. + (includeTerminal && candidate.terminal + ? true + : monotonicNow() - candidate.createdAt <= 30_000) ); if (candidates.length !== 1) { if (candidates.length > 1) { @@ -998,9 +1004,18 @@ function installGptEvidenceListeners(service: GoogleTagPubAdsService): void { if (instrumented.__tsAdTraceListeners) return; instrumented.__tsAdTraceListeners = true; const record = - (kind: 'gpt_slot_requested' | 'gpt_slot_response_received' | 'gpt_slot_onload') => + ( + kind: + | 'gpt_slot_requested' + | 'gpt_slot_response_received' + | 'gpt_slot_onload' + | 'gpt_impression_viewable' + ) => (event: GptSlotEvent): void => { - const candidate = candidateForSlot(event.slot, kind === 'gpt_slot_onload'); + const candidate = candidateForSlot( + event.slot, + kind === 'gpt_slot_onload' || kind === 'gpt_impression_viewable' + ); if (!candidate) return; window.tsjs?.recordAdTrace?.({ kind, @@ -1012,6 +1027,7 @@ function installGptEvidenceListeners(service: GoogleTagPubAdsService): void { service.addEventListener('slotRequested', record('gpt_slot_requested')); service.addEventListener('slotResponseReceived', record('gpt_slot_response_received')); service.addEventListener('slotOnload', record('gpt_slot_onload')); + service.addEventListener('impressionViewable', record('gpt_impression_viewable')); service.addEventListener('slotRenderEnded', (event: GptSlotEvent) => { const candidate = candidateForSlot(event.slot); if (!candidate) return; @@ -1060,12 +1076,7 @@ export function installTsAdInit(): void { kind: 'ts_auction_observed', slotId: slot.id, auctionTraceId: summary.auctionTraceId, - outcome: - summary.outcome === 'completed' || summary.outcome === 'no_bid' - ? 'no_bid' - : summary.outcome === 'skipped' - ? 'skipped' - : 'unresolved', + outcome: terminalSummaryStageOutcome(summary.outcome), confidence: 'definitive', reason: 'terminal_summary', }); diff --git a/crates/trusted-server-js/lib/test/core/ad_trace.test.ts b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts index 0348e8dd7..2a16a58e7 100644 --- a/crates/trusted-server-js/lib/test/core/ad_trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/ad_trace.test.ts @@ -5,6 +5,7 @@ import { AD_TRACE_MAX_RENDERS, AD_TRACE_MAX_SLOTS, createAdTraceStore, + terminalSummaryStageOutcome, } from '../../src/core/ad_trace'; const BID_TRACE_ID = '550e8400-e29b-41d4-a716-446655440000'; @@ -153,6 +154,50 @@ describe('ad trace reducer', () => { ).toBe('no_bid'); }); + it('retains a Trusted Server Prebid selection when bidWon arrives without claiming creative load', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'prebid_targeting_selected', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + outcome: 'won', + confidence: 'definitive', + reason: 'selected_targeting', + }); + store.record({ + kind: 'prebid_bid_won', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + }); + + expect(store.getSlot('slot-a')?.stages.prebid).toMatchObject({ + outcome: 'won', + reason: 'selected_targeting_with_bid_won', + }); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + }); + + it('preserves the direct iframe acknowledgement boundary without claiming GAM selection', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'creative_load_acknowledged', + slotId: 'slot-a', + generation, + bidTraceId: BID_TRACE_ID, + reason: 'direct_iframe_load', + }); + + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'load_acknowledged', + reason: 'direct_iframe_load', + }); + expect(store.getSlot('slot-a')?.stages.gam.outcome).toBe('not_observed'); + }); + it('classifies overlap, client Prebid, APS, no-bid, and superseded states', () => { const store = createAdTraceStore(() => 1); const generation = store.nextGeneration('slot-a'); @@ -272,6 +317,60 @@ describe('ad trace reducer', () => { }); }); + it('keeps GPT viewability separate from element visibility and creative load', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'gpt_slot_render_ended', + slotId: 'slot-a', + generation, + isEmpty: false, + }); + store.updateVisibility('slot-a', generation, 'hidden'); + store.record({ kind: 'gpt_impression_viewable', slotId: 'slot-a', generation }); + + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'gam_only', + visibility: 'hidden', + viewability: 'viewable', + }); + expect(store.getSlot('slot-a')?.stages.creative.outcome).toBe('not_observed'); + }); + + it('distinguishes APS renderer start from the validated ready boundary', () => { + const store = createAdTraceStore(() => 1); + const generation = store.nextGeneration('slot-a'); + store.record({ + kind: 'pb_render_served', + slotId: 'slot-a', + generation, + reason: 'direct_aps_renderer', + }); + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'renderer_served', + reason: 'direct_aps_renderer', + }); + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'served', + reason: 'direct_aps_renderer', + }); + + store.record({ + kind: 'aps_renderer_ready', + slotId: 'slot-a', + generation, + reason: 'direct_aps_renderer_ready', + }); + expect(store.getSlot('slot-a')?.stages.creative).toMatchObject({ + outcome: 'aps_renderer_ready', + reason: 'direct_aps_renderer_ready', + }); + expect(store.getRenderTimeline()[0]).toMatchObject({ + outcome: 'served', + reason: 'direct_aps_renderer_ready', + }); + }); + it('dispatches a frozen privacy-safe render event', () => { const store = createAdTraceStore(() => 1); const observed: unknown[] = []; @@ -303,6 +402,14 @@ describe('ad trace reducer', () => { expect(store.getRenderTimeline()[0].slotId).toBe('render-1'); }); + it('preserves failed and abandoned terminal summaries while mapping completed no-winner to no bid', () => { + expect(terminalSummaryStageOutcome('completed')).toBe('no_bid'); + expect(terminalSummaryStageOutcome('completed', true)).toBe('completed'); + expect(terminalSummaryStageOutcome('failed')).toBe('failed'); + expect(terminalSummaryStageOutcome('abandoned')).toBe('abandoned'); + expect(terminalSummaryStageOutcome('skipped')).toBe('skipped'); + }); + it('rejects malformed runtime event kinds and confidence values', () => { const store = createAdTraceStore(() => 1); store.record({ kind: 'not-a-real-kind', slotId: 'slot-a' } as never); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts index a3eaa7d6f..f24a513b7 100644 --- a/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/index.test.ts @@ -33,6 +33,33 @@ describe('ad_trace integration gate', () => { expect(document.querySelectorAll('#ts-ad-trace-overlay')).toHaveLength(1); }); + it.each(['failed', 'abandoned'] as const)( + 'preserves a %s terminal summary when seeding a slot', + async (outcome) => { + window.__tsjs_adTraceActive = true; + window.tsjs = { + adSlots: [{ id: 'slot-a' }], + auctionTrace: { + version: 1, + auctionTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'initial_navigation', + outcome, + }, + } as any; + + const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); + expect(installAdTrace()).toBe(true); + const generation = window.tsjs?.nextAdTraceGeneration?.('slot-a'); + expect( + window.tsjs?.adTrace?.getSlot('slot-a')?.generations[0]?.stages.trustedServer + ).toMatchObject({ + outcome, + reason: 'terminal_summary', + }); + expect(generation).toBeGreaterThan(0); + } + ); + it('does not accept the legacy tester cookie without bootstrap', async () => { document.cookie = 'ts-tester=true; Path=/'; const { installAdTrace } = await import('../../../src/integrations/ad_trace/index'); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts index 0a5aaa217..6e0770d21 100644 --- a/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/overlay.test.ts @@ -67,6 +67,15 @@ describe('ad trace overlay lifecycle', () => { } as any; const observe = vi.fn(); + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); vi.stubGlobal( 'ResizeObserver', class { @@ -89,6 +98,15 @@ describe('ad trace overlay lifecycle', () => { expect(rect).toHaveBeenCalledTimes(1); expect(observe).toHaveBeenCalledWith(element); expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'visible'); + const badge = shadow?.querySelector('.badge'); + const row = shadow?.querySelector('.row'); + expect(badge?.textContent).toBe( + 'Trusted Server selected a bid\nGAM rendered an ad — source not attributed\nSlot element currently visible' + ); + expect(row?.textContent).toContain('GAM rendered an ad — source not attributed'); + expect(`${badge?.textContent}\n${row?.textContent}`).not.toMatch( + /definitive|strong|probable|not_run|gam_only|TS winner|Prebid winner|#1/ + ); expect(element.getAttribute('data-ts-trace-seq')).toBe('1'); expect(element.getAttribute('data-ts-trace-outcome')).toBe('gam_only'); window.dispatchEvent(new Event('scroll')); @@ -107,4 +125,202 @@ describe('ad trace overlay lifecycle', () => { expect(element.hasAttribute('data-ts-trace-seq')).toBe(false); expect(updateVisibility).toHaveBeenCalledWith('slot-a', 1, 'disconnected'); }); + + it('does not add an empty badge when no operator-facing fact was observed', () => { + const element = document.createElement('div'); + document.body.appendChild(element); + window.tsjs = { + getAdTraceElement: () => element, + updateAdTraceVisibility: vi.fn(), + } as any; + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 300, + height: 250, + } as DOMRect); + const slot = { + slotId: 'slot-a', + latestGeneration: 1, + generations: [], + stages: { + trustedServer: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + prebid: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + }, + }; + const render = { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'unresolved', + confidence: 'none', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }; + installAdTraceOverlay( + { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => [render] as any, + export: () => + ({ + version: 1, + slots: [slot], + events: [], + renders: [render], + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }) as any, + }, + () => vi.fn() + ); + + expect(shadow?.querySelector('.badge')).toBeNull(); + expect(shadow?.querySelector('.row')?.textContent).toContain('No trace result observed'); + }); + + it('uses observed stage evidence when a render row has no render outcome', () => { + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + const stages = { + trustedServer: { outcome: 'won', confidence: 'definitive', reason: 'winner' }, + prebid: { outcome: 'won', confidence: 'definitive', reason: 'selected_targeting' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'render_failed', confidence: 'definitive', reason: 'failure' }, + }; + const slot = { + slotId: 'slot-a', + latestGeneration: 1, + generations: [{ generation: 1, stages }], + stages, + }; + const render = { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'unresolved', + confidence: 'none', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }; + installAdTraceOverlay( + { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => [render] as any, + export: () => + ({ + version: 1, + slots: [slot], + events: [], + renders: [render], + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }) as any, + }, + () => vi.fn() + ); + + const row = shadow?.querySelector('.row'); + expect(row?.textContent).toContain('Prebid reported render failed'); + expect(row?.textContent).not.toContain('No trace result observed'); + }); + + it('gives a retained render a factual status after its generation stages were evicted', () => { + const attachShadow = HTMLElement.prototype.attachShadow; + let shadow: ShadowRoot | undefined; + vi.spyOn(HTMLElement.prototype, 'attachShadow').mockImplementation(function ( + this: HTMLElement, + init: ShadowRootInit + ) { + shadow = attachShadow.call(this, init); + return shadow; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + const slot = { + slotId: 'slot-a', + latestGeneration: 2, + generations: [], + stages: { + trustedServer: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + prebid: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + gam: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + creative: { outcome: 'not_observed', confidence: 'none', reason: 'none' }, + }, + }; + const render = { + sequence: 1, + slotId: 'slot-a', + generation: 1, + source: 'gpt', + outcome: 'gam_only', + confidence: 'probable', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + }; + installAdTraceOverlay( + { + getSlot: () => slot as any, + getEvents: () => [], + getRenderTimeline: () => [render] as any, + export: () => + ({ + version: 1, + slots: [slot], + events: [], + renders: [render], + metadata: { droppedEvents: 0, evictedSlots: 0 }, + }) as any, + }, + () => vi.fn() + ); + + const row = shadow?.querySelector('.row'); + expect(row?.textContent).toContain('GAM rendered an ad — source not attributed'); + expect(row?.textContent).not.toMatch(/probable|gam_only|#1/); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts b/crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts new file mode 100644 index 000000000..d8008d1cb --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ad_trace/presentation.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; + +import { presentTraceOverlay } from '../../../src/integrations/ad_trace/presentation'; +import type { AdTraceStage, AdTraceStageName, RenderTraceSnapshot } from '../../../src/core/types'; + +function stage(outcome = 'not_observed', reason = 'none'): AdTraceStage { + return { outcome, confidence: 'none', reason }; +} + +function stages(overrides: Partial> = {}) { + return { + trustedServer: stage(), + prebid: stage(), + gam: stage(), + creative: stage(), + ...overrides, + }; +} + +function render( + outcome: RenderTraceSnapshot['outcome'], + overrides: Partial = {} +): RenderTraceSnapshot { + return { + sequence: 4, + slotId: 'slot-a', + generation: 2, + source: 'gpt', + outcome, + confidence: 'probable', + visibility: 'unknown', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe('presentTraceOverlay', () => { + it.each([ + [ + 'server winner', + stages({ trustedServer: stage('won') }), + undefined, + ['Trusted Server selected a bid'], + ], + [ + 'server no bid', + stages({ trustedServer: stage('no_bid') }), + undefined, + ['Trusted Server returned no bid'], + ], + [ + 'server skip', + stages({ trustedServer: stage('skipped') }), + undefined, + ['Trusted Server auction skipped'], + ], + [ + 'server failure', + stages({ trustedServer: stage('failed') }), + undefined, + ['Trusted Server auction did not complete'], + ], + [ + 'server abandonment', + stages({ trustedServer: stage('abandoned') }), + undefined, + ['Trusted Server auction did not complete'], + ], + [ + 'traced Prebid selection', + stages({ prebid: stage('won') }), + undefined, + ['Prebid selected the Trusted Server bid'], + ], + [ + 'client Prebid selection', + stages({ prebid: stage('client_bid_won') }), + undefined, + ['Prebid selected a client bid'], + ], + [ + 'client Prebid selection recorded as lost server targeting', + stages({ prebid: stage('lost') }), + undefined, + ['Prebid selected a client bid'], + ], + [ + 'reported Prebid win', + stages({ prebid: stage('won', 'selected_targeting_with_bid_won') }), + undefined, + ['Prebid selected the Trusted Server bid', 'Prebid reported the bid won'], + ], + ['GAM empty', stages({ gam: stage('empty') }), undefined, ['GAM returned no ad']], + ['GAM backfill', stages({ gam: stage('backfill') }), undefined, ['GAM returned backfill']], + [ + 'unattributed GAM render', + stages({ gam: stage('direct_or_unattributed') }), + undefined, + ['GAM rendered an ad — source not attributed'], + ], + [ + 'selected Trusted Server GAM creative', + stages({ gam: stage('trusted_server_won') }), + undefined, + ['GAM selected the Trusted Server creative'], + ], + [ + 'GAM iframe load', + stages({ creative: stage('gpt_iframe_onload') }), + undefined, + ['GAM creative iframe loaded'], + ], + [ + 'creative acknowledgement', + stages({ creative: stage('load_acknowledged') }), + undefined, + ['Trusted Server creative load confirmed'], + ], + [ + 'direct iframe acknowledgement', + stages({ creative: stage('load_acknowledged', 'direct_iframe_load') }), + render('confirmed', { source: 'direct_auction' }), + ['Creative iframe load confirmed'], + ], + [ + 'Prebid render success', + stages({ creative: stage('prebid_render_succeeded') }), + undefined, + ['Prebid reported render succeeded'], + ], + [ + 'Prebid render failure', + stages({ creative: stage('render_failed') }), + undefined, + ['Prebid reported render failed'], + ], + [ + 'direct APS renderer started', + stages({ creative: stage('renderer_served', 'direct_aps_renderer') }), + render('served', { source: 'direct_auction', reason: 'direct_aps_renderer' }), + ['APS renderer started creative loading'], + ], + [ + 'direct APS renderer ready', + stages({ creative: stage('aps_renderer_ready', 'direct_aps_renderer_ready') }), + render('served', { source: 'direct_auction', reason: 'direct_aps_renderer_ready' }), + ['APS renderer reported ready'], + ], + [ + 'direct render rejection', + stages({ creative: stage('rejected') }), + undefined, + ['Trusted Server direct render rejected'], + ], + [ + 'current visibility', + stages(), + render('unresolved', { visibility: 'visible' }), + ['Slot element currently visible'], + ], + [ + 'viewable impression independent of live visibility', + stages({ creative: stage('gpt_iframe_onload') }), + render('gam_only', { visibility: 'hidden', viewability: 'viewable' }), + [ + 'GAM creative iframe loaded', + 'GAM rendered an ad — source not attributed', + 'Slot element currently hidden', + 'Viewable impression observed', + ], + ], + ])('%s uses factual operator language', (_name, input, snapshot, expected) => { + expect(presentTraceOverlay(input, snapshot).facts).toEqual(expected); + }); + + it('hides unobserved and inapplicable stages without leaking internal vocabulary', () => { + const presentation = presentTraceOverlay( + stages({ + trustedServer: stage('unresolved'), + prebid: stage('not_run', 'direct'), + gam: stage('not_observed'), + creative: stage('not_observed'), + }), + render('unresolved', { visibility: 'unknown' }) + ); + + expect(presentation.facts).toEqual([]); + expect(presentation.renderStatus).toBeUndefined(); + expect(JSON.stringify(presentation)).not.toMatch( + /definitive|strong|probable|not_run|not_observed|unresolved|gam_only|client_bid_won/ + ); + }); + + it.each([ + ['attributed', stages({ creative: stage('load_acknowledged') }), undefined], + ['empty', stages({ gam: stage('empty') }), undefined], + ['failed', stages({ creative: stage('render_failed') }), undefined], + ['unattributed', stages({ gam: stage('trusted_server_candidate') }), render('gam_only')], + ] as const)('uses an evidence-based %s presentation class', (expected, input, snapshot) => { + expect(presentTraceOverlay(input, snapshot).className).toBe(expected); + }); + + it.each([ + [ + 'confirmed Trusted Server creative', + render('confirmed'), + 'Trusted Server creative load confirmed', + ], + [ + 'confirmed direct creative', + render('confirmed', { source: 'direct_auction' }), + 'Creative iframe load confirmed', + ], + ['served renderer', render('served'), 'Creative response sent to the renderer'], + ['unattributed GAM render', render('gam_only'), 'GAM rendered an ad — source not attributed'], + ['empty GAM response', render('empty'), 'GAM returned no ad'], + ] as const)('renders %s as a concise factual row status', (_name, snapshot, expected) => { + expect(presentTraceOverlay(stages(), snapshot).renderStatus).toBe(expected); + }); + + it('falls back to the strongest observed stage fact for a primary row', () => { + const presentation = presentTraceOverlay( + stages({ creative: stage('render_failed') }), + render('unresolved') + ); + + expect(presentation.renderStatus).toBeUndefined(); + expect(presentation.primaryStatus).toBe('Prebid reported render failed'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 24f900123..ec3d7c681 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -138,6 +138,127 @@ describe('installTsAdInit', () => { fetchSpy.mockRestore(); }); + it.each(['failed', 'abandoned'] as const)( + 'preserves a %s terminal summary when adInit has no traced bid', + async (outcome) => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const recordAdTrace = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + auctionTrace: { + version: 1, + auctionTraceId: '550e8400-e29b-41d4-a716-446655440000', + source: 'initial_navigation', + outcome, + }, + recordAdTrace, + } as any; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'ts_auction_observed', + outcome, + reason: 'terminal_summary', + }) + ); + } + ); + + it('records late GPT viewability on the exact terminal request generation', async () => { + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const listeners: Record void>> = {}; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + clearTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn((key: string) => { + if (key === 'hb_adid') return ['client-ad-id']; + if (key === 'hb_bidder') return ['client-bidder']; + return []; + }), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn((event: string, fn: (value: SlotRenderEvent) => void) => { + (listeners[event] ??= []).push(fn); + }), + refresh: vi.fn(), + }; + const recordAdTrace = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + recordAdTrace, + nextAdTraceGeneration: vi.fn().mockReturnValue(1), + } as any; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + now = 1; + listeners.slotRenderEnded?.forEach((listener) => listener({ isEmpty: false, slot: mockSlot })); + now = 60_001; + listeners.impressionViewable?.forEach((listener) => + listener({ isEmpty: false, slot: mockSlot }) + ); + + expect(recordAdTrace).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'gpt_impression_viewable', + slotId: 'atf_sidebar_ad', + generation: 1, + }) + ); + }); + it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), From 5094f6fda724fd6e5ee2d8f496a2d09d0b663748 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 23 Jul 2026 17:06:45 -0500 Subject: [PATCH 109/494] Trace publisher-owned GPT slot lifecycles --- .../tests/ad-trace/auction-trace.spec.ts | 266 +++++++++ .../frameworks/ad-trace/public/index.php | 77 ++- .../frameworks/ad-trace/public/router.php | 2 +- .../lib/src/core/ad_trace.ts | 4 +- .../src/integrations/ad_trace/presentation.ts | 6 +- .../lib/src/integrations/gpt/index.ts | 551 +++++++++++++++--- .../lib/test/core/ad_trace.test.ts | 45 ++ .../ad_trace/presentation.test.ts | 20 + .../lib/test/integrations/gpt/ad_init.test.ts | 438 ++++++++++++++ 9 files changed, 1306 insertions(+), 103 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts index 075c30b57..56db80bc9 100644 --- a/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/ad-trace/auction-trace.spec.ts @@ -110,6 +110,75 @@ test.describe("tester-only auction trace contract", () => { ).toBe("undefined"); }); + test("publisher-only pages install universal GPT tracing without adInit", async ({ + page, + }) => { + await serveBuiltPrebid(page); + await page.goto(runtimeUrl("/publisher-only?ts_console=1"), { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(runtimeUrl("/publisher-only")); + await expect + .poll(() => + page.evaluate( + () => + typeof ( + window as Window & { + tsjs?: { adTrace?: unknown }; + } + ).tsjs?.adTrace, + ), + ) + .toBe("object"); + + await page.evaluate(() => { + ( + window as Window & { + adTraceFixture: { + requestPublisherLazy(flags: { + isBackfill?: boolean; + }): void; + }; + } + ).adTraceFixture.requestPublisherLazy({ isBackfill: true }); + }); + await expect + .poll(() => + page.evaluate(() => { + const slots = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + slots: Array<{ + slotId: string; + stages: { + trustedServer: { + outcome: string; + }; + gam: { outcome: string }; + }; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export().slots; + const slot = slots.find((item) => + item.slotId.startsWith("gpt_slot_"), + ); + return slot + ? { + trustedServer: + slot.stages.trustedServer.outcome, + gam: slot.stages.gam.outcome, + } + : undefined; + }), + ) + .toEqual({ trustedServer: "not_observed", gam: "backfill" }); + }); + test("console session supports true, persists privately, and can be disabled", async ({ page, }) => { @@ -222,6 +291,203 @@ test.describe("tester-only auction trace contract", () => { ); }); + test("publisher-owned lazy GPT slots receive factual overlays without TS ownership", async ({ + page, + }) => { + await openTesterPage(page); + await page.locator("#publisher-lazy-slot").scrollIntoViewIfNeeded(); + await page.evaluate(() => { + const fixture = ( + window as Window & { + adTraceFixture: { + requestPublisherLazy(flags: { + isBackfill?: boolean; + isEmpty?: boolean; + }): void; + }; + } + ).adTraceFixture; + fixture.requestPublisherLazy({ isBackfill: true }); + }); + + await expect + .poll(() => + page.evaluate(() => { + const result = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + slots: Array<{ + slotId: string; + latestGeneration: number; + stages: Record< + string, + { outcome: string } + >; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export(); + const slot = result.slots.find((item) => + item.slotId.startsWith("gpt_slot_"), + ); + return slot + ? { + slotId: slot.slotId, + generation: slot.latestGeneration, + trustedServer: + slot.stages.trustedServer.outcome, + prebid: slot.stages.prebid.outcome, + gam: slot.stages.gam.outcome, + creative: slot.stages.creative.outcome, + } + : undefined; + }), + ) + .toEqual( + expect.objectContaining({ + slotId: expect.stringMatching(/^gpt_slot_\d+$/), + trustedServer: "not_observed", + prebid: "not_observed", + gam: "backfill", + creative: "gpt_iframe_onload", + }), + ); + + await page.evaluate(() => { + ( + window as Window & { + adTraceFixture: { markPublisherLazyViewable(): void }; + } + ).adTraceFixture.markPublisherLazyViewable(); + }); + await expect + .poll(() => + page.evaluate(() => { + const result = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + renders: Array<{ + slotId: string; + viewability?: string; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export(); + return result.renders.find((item) => + item.slotId.startsWith("gpt_slot_"), + )?.viewability; + }), + ) + .toBe("viewable"); + + await expect(page.locator("#publisher-lazy-slot")).toHaveAttribute( + "data-ts-trace-outcome", + "gam_only", + ); + const genericExport = await page.evaluate(() => + JSON.stringify( + ( + window as Window & { + tsjs: { adTrace: { export(): unknown } }; + } + ).tsjs.adTrace.export(), + ), + ); + expect(genericExport).not.toContain("publisher-lazy-slot"); + expect(genericExport).not.toContain("/123456789/example/publisher-lazy"); + const session = await page.context().newCDPSession(page); + await expect + .poll(async () => { + const tree = (await session.send( + "Accessibility.getFullAXTree", + )) as { + nodes: Array<{ name?: { value?: string } }>; + }; + return tree.nodes + .map((node) => node.name?.value || "") + .join("\n"); + }) + .toContain("GAM returned backfill"); + + const firstGeneration = await page.evaluate(() => { + const result = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + slots: Array<{ + slotId: string; + latestGeneration: number; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export(); + return result.slots.find((item) => + item.slotId.startsWith("gpt_slot_"), + )?.latestGeneration; + }); + await page.evaluate(() => { + ( + window as Window & { + adTraceFixture: { + requestPublisherLazy(flags: { + isEmpty?: boolean; + }): void; + }; + } + ).adTraceFixture.requestPublisherLazy({ isEmpty: true }); + }); + await expect + .poll(() => + page.evaluate((previousGeneration) => { + const result = ( + window as Window & { + tsjs: { + adTrace: { + export(): { + slots: Array<{ + slotId: string; + latestGeneration: number; + stages: Record< + string, + { outcome: string } + >; + }>; + }; + }; + }; + } + ).tsjs.adTrace.export(); + const slot = result.slots.find((item) => + item.slotId.startsWith("gpt_slot_"), + ); + return slot + ? { + generationAdvanced: + slot.latestGeneration > previousGeneration, + gam: slot.stages.gam.outcome, + creative: slot.stages.creative.outcome, + } + : undefined; + }, firstGeneration ?? 0), + ) + .toEqual({ + generationAdvanced: true, + gam: "empty", + creative: "not_observed", + }); + }); + test("direct auction API render reaches an exact iframe-load acknowledgement", async ({ page, }) => { diff --git a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php index e7bfca062..89e2f0076 100644 --- a/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php +++ b/crates/trusted-server-integration-tests/fixtures/frameworks/ad-trace/public/index.php @@ -6,8 +6,9 @@ Trusted Server ad trace fixture +``` + +For `[]`, omit the property with `skip_serializing_if`, matching the existing +`clientSideBidders` convention. A browser that receives no property treats it as an +empty list. This makes upgrade and rollback backwards compatible: old configuration +has no behavior change; an older external bundle safely ignores the extra injected +property; and a newer bundle with old configuration has no exclusions. + +## 5. Matching and refresh behavior + +### 5.1 Match predicate + +Extend `RefreshGptSlot` with: + +```ts +getAdUnitPath?: () => string; +``` + +At each publisher refresh, derive a `Set` from +`getInjectedConfig()?.excludedGamAdUnitPathSuffixes ?? []`. A slot is excluded only +when all of the following hold: + +1. The normalized set is non-empty. +2. `slot.getAdUnitPath` is a function. +3. Calling it returns a string. +4. The returned GAM path `endsWith()` at least one configured suffix, using exact, + case-sensitive JavaScript string comparison. + +Do not derive paths from the element ID or injected `adSlots` metadata. Do not use +`getSizes()` as a fallback. A missing getter, a non-string return value, an empty +path, or a getter that throws is **fail-open**: the slot remains auction-eligible. +The implementation catches only the getter failure around that call; it neither +suppresses the GPT refresh nor broadens an exclusion because telemetry is absent. + +A matching path is excluded only from the synthetic refresh auction. It is not +removed from GPT's target list. + +### 5.2 Required algorithm + +Keep the existing `adInitRefreshInProgress` check as the first branch, before slot +resolution, targeting cleanup, and path inspection: + +```text +if adInitRefreshInProgress: + originalRefresh(slots, options) + return + +targetSlots = explicit slots, or pubads.getSlots() for bare refresh +if targetSlots is empty: + originalRefresh(slots, options) + return + +clear TS/Prebid refresh-targeting keys from every target slot +auctionSlots = targetSlots excluding suffix-matched slots + +if auctionSlots is empty: + originalRefresh(targetSlots, options) + return + +adUnits = synthetic refresh ad units for auctionSlots only +pbjs.requestBids({ adUnits, timeout, bidsBackHandler }) +bidsBackHandler: + pbjs.setTargetingForGPTAsync(auction-slot codes only) + originalRefresh(targetSlots, options) +``` + +Build candidate codes, recover publisher bidder params, and recover client-side bids +only for `auctionSlots`; excluded slots must not be represented in `adUnits` at all. +The existing scoped targeting behavior therefore continues to affect only eligible +slots. + +### 5.3 Refresh sequences + +| Call and slot set | Prebid behavior | GPT behavior | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `refresh([normal], options)` | Auction `normal`, target its synthetic code after bids return. | Refresh `[normal]` with the same options after the callback. | +| `refresh([excluded], options)` | Clear TS/Prebid keys; do not call `requestBids()` or `setTargetingForGPTAsync()`. | Immediately refresh `[excluded]` with the same options. | +| Bare `refresh(options)`; all slots excluded | Resolve `pubads.getSlots()`, clear their TS/Prebid keys, then make no Prebid calls. | Immediately refresh the resolved complete slot list with the same options. | +| Bare `refresh(options)`; mixed normal and excluded slots | Clear every target slot; auction and target only normal slots. | After the callback, refresh the complete resolved list, including excluded slots. | +| Any refresh while `adInitRefreshInProgress` is true | No cleanup, match, auction, or targeting. | Directly pass through the original `slots` and options unchanged. | +| Missing/throwing `getAdUnitPath()` | Treat the slot as normal and auction it. | Existing post-auction refresh behavior. | + +Passing the resolved list in the all-excluded and mixed cases is deliberate: it is +the same concrete list used for cleanup and makes the final GAM refresh list +explicit. The original options object is passed through unchanged. + +### 5.4 Targeting and initial-load invariants + +The cleanup step remains before filtering and is limited to the existing +`TS_REFRESH_TARGETING_KEYS`. It removes stale Trusted Server/Prebid winner data +from excluded slots so GAM cannot serve using an obsolete header-bid winner, while +preserving GAM path metadata and every unrelated publisher targeting key. + +`adInitRefreshInProgress` continues to bypass cleanup and auctioning directly. This +preserves `disableInitialLoad()` and the initial Trusted Server targeting handoff: +that one internal refresh must deliver already-applied targeting to GAM instead of +being converted into a client-side refresh auction. + +## 6. Implementation areas + +| File | Planned change | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Add config field, validation/canonicalization, head-injected camel-case array, and Rust tests. | +| `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` | Add injected config/type support, guarded path matcher, and filter `targetSlots` into `auctionSlots` after cleanup. | +| `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` | Add explicit/global/mixed/fail-open refresh tests. | +| `trusted-server.example.toml` | Add a commented fictional configuration example. | +| `docs/guide/integrations/prebid.md` | Document the field, exact matcher semantics, and GAM-preservation caveat. | +| `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` | Update only if implementation exposes a necessary design correction. | +| `docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md` | Mark implementation evidence/status only if project practice requires it. | + +No generated `dist` file, minified external bundle, or publisher source file is a +source-of-truth edit target. + +## 7. Test matrix + +### Rust configuration and injection + +- Default/omitted field yields `Vec::new()` and omits + `excludedGamAdUnitPathSuffixes` from injected JSON. +- A valid array parses, normalizes exact duplicates to one entry in declaration + order, and injects the expected camel-case array. +- Empty, whitespace-padded, whitespace-only, missing-leading-slash, and `/` values + fail enabled Prebid configuration validation with field-specific errors. +- Existing Prebid config/head-injector tests continue to pass with the new empty + field initialized in helper literals. + +### Browser refresh wrapper + +- Normal explicit slot with a nonmatching path still calls `requestBids()`, creates + its ad unit, scopes targeting to its code, and refreshes it after the callback. +- Explicit matching slot clears each existing TS/Prebid key, does not call + `requestBids()` or `setTargetingForGPTAsync()`, and immediately calls original + GPT refresh with the exact slot array and options. +- Global all-excluded slots resolve through `getSlots()`, clear every target, make + no Prebid calls, and refresh the complete resolved list with options. +- Global mixed slots clear both categories, auction only eligible slots, scope + targeting to eligible synthetic codes, and refresh the complete list after bids + return. +- Missing `getAdUnitPath`, non-string path, and a throwing getter each fail open to + the normal auction path without throwing from the wrapper. +- Case mismatch and a trailing-slash mismatch do not exclude, proving literal + case-sensitive suffix behavior. +- Existing `adInitRefreshInProgress` test still proves direct pass-through without + cleanup or auction; existing normal refresh and client-side-bid recovery tests + remain green. + +## 8. External bundle and browser verification + +`crates/trusted-server-js/lib/build-prebid-external.mjs` is the supported source +build path for the immutable external Prebid bundle; `build-all.mjs` intentionally +does not build Prebid. Implementers must change TypeScript source and regenerate a +new external bundle through the supported `ts prebid bundle` workflow (or its +underlying supported generator), not edit a generated/minified asset. + +Roll out the application/config and the regenerated external bundle together: + +1. Build and test the source change. +2. Generate and upload the new external bundle. +3. Update the operator bundle URL/hash/SRI metadata as required by the existing + bundle workflow and deploy the Trusted Server application/config containing the + suffix list. +4. Verify the first-party bundle URL resolves to the new bytes and the injected + `window.__tsjs_prebid.excludedGamAdUnitPathSuffixes` has the expected values. +5. In browser instrumentation, verify a matching slot calls GPT refresh without a + corresponding Trusted Server refresh `/auction` request, while a normal display + slot in the same global refresh still produces `/auction` and receives refreshed + Prebid targeting. +6. Verify GAM records the excluded slot's request/impression. Use a controlled + staging page or harness rather than relying on the unstable production host. + +A config-only deployment with an old cached external bundle cannot apply the browser +filter; a bundle-only deployment without the injected configuration remains a no-op. + +## 9. Operational caveats and risks + +- The exclusion is limited to Trusted Server's wrapper around GPT refresh. It does + not block a publisher's unrelated direct `pbjs.requestBids()`, APS calls, direct + `/auction` use, or any other auction wrapper. +- The feature relies on GPT's supported `getAdUnitPath()` API. A missing or throwing + getter deliberately fails open, which may continue auctioning a tracking slot + rather than risk silently suppressing display inventory. +- Literal suffix matching can be over-broad if an operator chooses a generic suffix + such as `/only`; use a unique terminal GAM path segment and validate on a staging + page. `/` is rejected, but other overly broad valid values remain an operator + responsibility. +- Excluded slots have only Trusted Server/Prebid targeting cleared; unrelated GAM + targeting and GAM request behavior are intentionally untouched. +- Browser code and injected config must reach the same deployed page. Cache/version + rollout mistakes are the primary operational risk. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 69df213e3..1e27e2544 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -45,6 +45,9 @@ timeout_ms = 1000 bidders = [] debug = false client_side_bidders = [] +# Keep selected GAM inventory out of Trusted Server's Prebid refresh auctions. +# Matching slots still refresh through GAM. +# excluded_gam_ad_unit_path_suffixes = ["/trackingonly"] # Runtime bundle metadata. Set these after running `ts prebid bundle` and uploading the bundle. # external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-.js" # external_bundle_sha256 = "" From 76c56263fa780dbd7b6d0f1fd9e32099516e9f6e Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 13:09:53 -0500 Subject: [PATCH 119/494] Prevent duplicate GPT slot requests --- .../src/integrations/gpt.rs | 29 +++ .../src/integrations/gpt_bootstrap.js | 160 +++++++++++-- .../trusted-server-js/lib/src/core/types.ts | 18 ++ .../lib/src/integrations/gpt/index.ts | 164 ++++++++++++- .../lib/test/integrations/gpt/ad_init.test.ts | 174 +++++++++++++- ...-24-prevent-duplicate-gpt-slot-requests.md | 219 ++++++++++++++++++ ...vent-duplicate-gpt-slot-requests-design.md | 165 +++++++++++++ 7 files changed, 900 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md create mode 100644 docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index e21058a21..f53a21cf0 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -1222,6 +1222,35 @@ mod tests { ); } + #[test] + fn head_inserts_bootstrap_installs_inner_div_slot_handoff() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let combined = integration.head_inserts(&ctx).join(""); + assert!( + combined.contains("gptSlotHandoffs"), + "bootstrap should keep late publisher slot handoff state on window.tsjs" + ); + assert!( + combined.contains("__tsSlotHandoffPatched"), + "bootstrap should install idempotent GPT handoff wrappers" + ); + assert!( + combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), + "bootstrap should define the TS fallback on the actual inner div" + ); + assert!( + !combined.contains("actualDivId + \"-container\""), + "bootstrap must not define a competing outer-container GPT slot" + ); + } + #[test] fn head_inserts_bootstrap_guards_enable_services_with_idempotency_flag() { let config = test_config(); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index cc4c5c00c..0c2697357 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -42,6 +42,127 @@ pubads.__tsInitialLoadHooked = true; }); + function findSlotByElementId(pubads, elementId) { + var slots = pubads.getSlots ? pubads.getSlots() : []; + return ( + slots.find(function (slot) { + return slot.getSlotElementId() === elementId; + }) || null + ); + } + + function runHandoffInternal(callback) { + var wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } + } + + // TS cannot wait an arbitrary amount of time for a framework to define a + // slot: publishers that never define one would render blank. Instead, TS + // defines its fallback on the actual inner div and aliases only a later + // publisher defineSlot() for that exact div to the same GPT slot. + function installSlotHandoff() { + window.googletag.cmd.push(function () { + var tag = window.googletag; + var pubads = tag.pubads && tag.pubads(); + if (!tag.defineSlot || !tag.display || !pubads) return; + + if (!tag.defineSlot.__tsSlotHandoffPatched) { + var originalDefineSlot = tag.defineSlot.bind(tag); + var patchedDefineSlot = function (adUnitPath, formats, elementId) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if (!ts.gptSlotHandoffInternal && handoff) { + var existingSlot = findSlotByElementId(pubads, elementId); + if (existingSlot) { + if (!handoff.publisherClaimed) { + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = + ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots || []).filter( + function (ownedSlot) { + return ownedSlot !== existingSlot; + }, + ); + if ( + handoff.gamUnitPath !== adUnitPath || + JSON.stringify(handoff.formats) !== JSON.stringify(formats) + ) { + ts.log && + ts.log.warn && + ts.log.warn( + "GPT slot handoff: publisher definition differs from TS configuration", + elementId, + ); + } + } + return existingSlot; + } + } + return originalDefineSlot(adUnitPath, formats, elementId); + }; + patchedDefineSlot.__tsSlotHandoffPatched = true; + tag.defineSlot = patchedDefineSlot; + } + + if (!tag.display.__tsSlotHandoffPatched) { + var originalDisplay = tag.display.bind(tag); + var patchedDisplay = function (elementId) { + var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; + if ( + !ts.gptSlotHandoffInternal && + handoff && + handoff.suppressPublisherDisplay + ) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(elementId); + }; + patchedDisplay.__tsSlotHandoffPatched = true; + tag.display = patchedDisplay; + } + + if (!pubads.refresh.__tsSlotHandoffPatched) { + var originalRefresh = pubads.refresh.bind(pubads); + var patchedRefresh = function (requestedSlots) { + if (ts.gptSlotHandoffInternal) { + originalRefresh(requestedSlots); + return; + } + var slots = + requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); + if (!slots) { + originalRefresh(requestedSlots); + return; + } + var suppressed = false; + var remainingSlots = slots.filter(function (slot) { + var handoff = + ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + originalRefresh(requestedSlots); + } else if (remainingSlots.length > 0) { + originalRefresh(remainingSlots); + } + }; + patchedRefresh.__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); + } + + installSlotHandoff(); + ts.adInit = function () { var slots = ts.adSlots || []; var bids = ts.bids || {}; @@ -88,15 +209,26 @@ }) || null; var tsOwned = false; if (!s) { - // Use outer container div for TS's slot when publisher hasn't defined - // theirs yet — keeps both slots on separate divs so publisher's - // later defineSlot on the inner div doesn't conflict. - var containerEl = document.getElementById(actualDivId + "-container"); - var slotDivId = containerEl ? containerEl.id : actualDivId; - s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + // Define TS's fallback on the publisher's actual div. The scoped + // handoff wrapper returns this slot if the publisher defines it later. + s = runHandoffInternal(function () { + return googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + actualDivId, + ); + }); if (!s) return; s.addService(googletag.pubads()); tsOwned = true; + ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; + ts.gptSlotHandoffs[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } Object.entries(slot.targeting || {}).forEach(function (e) { @@ -113,11 +245,9 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - // Map both the inner div and the GPT slot's element ID (the - // "-container" div when TS defined the slot there) into divToSlotId. - // This bootstrap fires no beacons and registers no slotRenderEnded - // listener; the map is consumed by the bundle's render bridge (index.ts) - // once it loads, which reports the GPT slot element ID. + // Map the resolved inner div to the slot ID. This bootstrap fires no + // beacons and registers no slotRenderEnded listener; the map is consumed + // by the bundle's render bridge (index.ts) once it loads. divToSlotId[actualDivId] = slot.id; var slotElementId = s.getSlotElementId(); if (slotElementId && slotElementId !== actualDivId) { @@ -143,7 +273,9 @@ // impression. Runs after enableServices(); on SPA navigation services are // already enabled, so this runs unconditionally for new slots. slotsToDisplay.forEach(function (divId) { - googletag.display(divId); + runHandoffInternal(function () { + googletag.display(divId); + }); }); // Reused publisher-owned slots always need a refresh to pick up the // server-side targeting. TS-defined slots are fetched by display() above @@ -161,7 +293,9 @@ // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. ts.adInitRefreshInProgress = true; try { - googletag.pubads().refresh(slotsNeedingRefresh); + runHandoffInternal(function () { + googletag.pubads().refresh(slotsNeedingRefresh); + }); } finally { ts.adInitRefreshInProgress = false; } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 360e2aa49..cd25133d1 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -63,6 +63,20 @@ export interface AuctionBidData { debug_bid?: AuctionDebugBidData; } +/** + * Lifecycle state for a GPT slot TS created before its publisher declares it. + * + * Stored on `window.tsjs` so the head bootstrap and the full TSJS bundle share + * one handoff protocol. + */ +export interface GptSlotHandoff { + gamUnitPath: string; + formats: Array<[number, number]>; + publisherClaimed: boolean; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -121,6 +135,10 @@ export interface TsjsApi { * defined slots so they are not left blank. */ gptInitialLoadDisabled?: boolean; + /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ + gptSlotHandoffs?: Record; + /** True only while TS calls a GPT function that the handoff wrappers observe. */ + gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ spaHookInstalled?: boolean; } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca4689684..8853997c3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; +import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -445,9 +445,143 @@ function installInitialLoadDetector(ts: TsjsApi): void { }); } +interface HandoffPatchedFunction { + __tsSlotHandoffPatched?: boolean; +} + +function findGptSlotByElementId( + pubads: GoogleTagPubAdsService, + elementId: string +): GoogleTagSlot | undefined { + return pubads.getSlots?.().find((slot) => slot.getSlotElementId() === elementId); +} + +function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { + return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; +} + +function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { + const wasInternal = ts.gptSlotHandoffInternal; + ts.gptSlotHandoffInternal = true; + try { + return callback(); + } finally { + ts.gptSlotHandoffInternal = wasInternal; + } +} + +/** + * Reuse a TS-created inner-div slot when its publisher defines that div later. + * + * TS cannot wait an arbitrary amount of time for framework hydration: doing so + * would leave placements blank when no publisher slot is ever defined. Instead, + * TS creates its fallback on the publisher's actual div and aliases only a later + * `defineSlot()` for that exact div. The first duplicate publisher request is + * suppressed because TS has already issued the initial request with TS targeting. + */ +function installLatePublisherSlotHandoff(ts: TsjsApi): void { + const win = window as GptWindow; + const cmd = win.googletag?.cmd; + if (!cmd) return; + + cmd.push(() => { + const g = win.googletag; + const pubads = g?.pubads?.(); + if (!g?.defineSlot || !g.display || !pubads) return; + + const defineSlot = g.defineSlot; + if (!(defineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalDefineSlot = defineSlot.bind(g); + const patchedDefineSlot = ( + adUnitPath: string, + formats: Array, + elementId: string + ): GoogleTagSlot | null => { + const handoff = ts.gptSlotHandoffs?.[elementId]; + if (!ts.gptSlotHandoffInternal && handoff) { + const existingSlot = findGptSlotByElementId(pubads, elementId); + if (existingSlot) { + if (!handoff.publisherClaimed) { + handoff.publisherClaimed = true; + handoff.suppressPublisherDisplay = true; + handoff.suppressPublisherRefresh = ts.gptInitialLoadDisabled === true; + ts.prevGptSlots = (ts.prevGptSlots ?? []).filter( + (ownedSlot) => ownedSlot !== existingSlot + ); + if ( + handoff.gamUnitPath !== adUnitPath || + JSON.stringify(handoff.formats) !== JSON.stringify(formats) + ) { + log.warn('GPT slot handoff: publisher definition differs from TS configuration', { + elementId, + tsGamUnitPath: handoff.gamUnitPath, + publisherGamUnitPath: adUnitPath, + }); + } + } + return existingSlot; + } + } + return originalDefineSlot(adUnitPath, formats, elementId); + }; + (patchedDefineSlot as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.defineSlot = patchedDefineSlot; + } + + const display = g.display; + if (!(display as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalDisplay = display.bind(g); + const patchedDisplay = (elementId: string): void => { + const handoff = ts.gptSlotHandoffs?.[elementId]; + if (!ts.gptSlotHandoffInternal && handoff?.suppressPublisherDisplay) { + handoff.suppressPublisherDisplay = false; + return; + } + originalDisplay(elementId); + }; + (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + g.display = patchedDisplay; + } + + const refresh = pubads.refresh; + if (!(refresh as HandoffPatchedFunction).__tsSlotHandoffPatched) { + const originalRefresh = refresh.bind(pubads); + const patchedRefresh = (requestedSlots?: GoogleTagSlot[]): void => { + if (ts.gptSlotHandoffInternal) { + originalRefresh(requestedSlots); + return; + } + + const slots = requestedSlots ?? pubads.getSlots?.(); + if (!slots) { + originalRefresh(requestedSlots); + return; + } + + let suppressed = false; + const remainingSlots = slots.filter((slot) => { + const handoff = handoffForSlot(ts, slot); + if (!handoff?.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + }); + if (!suppressed) { + originalRefresh(requestedSlots); + } else if (remainingSlots.length > 0) { + originalRefresh(remainingSlots); + } + }; + (patchedRefresh as HandoffPatchedFunction).__tsSlotHandoffPatched = true; + pubads.refresh = patchedRefresh; + } + }); +} + export function installTsAdInit(): void { const ts = (window.tsjs ??= {} as TsjsApi); installInitialLoadDetector(ts); + installLatePublisherSlotHandoff(ts); ts.adInit = function () { const slots = ts.adSlots ?? []; // Snapshot bids at adInit() call time — correct for targeting setup. @@ -517,16 +651,23 @@ export function installTsAdInit(): void { if (existingSlot) { gptSlot = existingSlot; } else { - // Use outer container div for TS's slot when publisher hasn't defined - // theirs yet — keeps both slots on separate divs so publisher's - // later defineSlot on the inner div doesn't conflict. - const containerEl = document.getElementById(`${actualDivId}-container`); - const slotDivId = containerEl?.id ?? actualDivId; - const defined = g.defineSlot?.(slot.gam_unit_path, slot.formats, slotDivId); + // Define TS's fallback on the publisher's actual div. A late publisher + // defineSlot() for this div is handed the same slot by the scoped GPT + // wrapper, preventing a competing container-slot request. + const defined = withGptSlotHandoffInternal(ts, () => + g.defineSlot?.(slot.gam_unit_path, slot.formats, actualDivId) + ); if (!defined) return; defined.addService(g.pubads!()); gptSlot = defined; tsOwned = true; + (ts.gptSlotHandoffs ??= {})[actualDivId] = { + gamUnitPath: slot.gam_unit_path, + formats: slot.formats, + publisherClaimed: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, + }; } const slotDivId2 = gptSlot.getSlotElementId?.() ?? actualDivId; @@ -541,9 +682,8 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); - // Map both inner div and container div → slot ID so slotRenderEnded - // (which reports the GPT slot's div, i.e. slotDivId/container) can look up - // the slot, while adm injection (which targets the inner div) also works. + // Map the resolved inner div to the slot ID so slotRenderEnded and ADM + // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; if (slotDivId2 !== actualDivId) divToSlotId[slotDivId2] = slot.id; const slotTargetingKeys = Object.keys(slot.targeting ?? {}); @@ -607,7 +747,7 @@ export function installTsAdInit(): void { // called without a matching display call") and misses its impression. // Must run after enableServices(); on SPA navigation services are already // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => g.display?.(divId)); + slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); // Slots needing an explicit ad request via refresh(). Reused // publisher-owned slots always need one to pick up the just-applied @@ -630,7 +770,7 @@ export function installTsAdInit(): void { // the same slots still go through the wrapper normally. ts.adInitRefreshInProgress = true; try { - g.pubads!().refresh(slotsNeedingRefresh); + withGptSlotHandoffInternal(ts, () => g.pubads!().refresh(slotsNeedingRefresh)); } finally { ts.adInitRefreshInProgress = false; } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 4a6368768..f99d90fa7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -145,12 +145,13 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, }; const defineSlotMock = vi.fn().mockReturnValue(mockSlot); const displayMock = vi.fn(); @@ -184,7 +185,171 @@ describe('installTsAdInit', () => { expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot // that was never displayed). - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(nativeRefresh).not.toHaveBeenCalled(); + }); + + it('hands a late publisher definition the TS inner-div slot without a second request', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const requests: string[] = []; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn((requestedSlots?: FakeSlot[]) => { + (requestedSlots ?? Array.from(slots.values())).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); + const destroySlots = vi.fn(); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + destroySlots, + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherDefineSlot = googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot; + const publisherDisplay = googletag.display as unknown as (elementId: string) => void; + const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + publisherSlot.addService(pubads); + publisherDisplay('div-atf-sidebar'); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['div-atf-sidebar']); + expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); + + (window as TestWindow).tsjs!.adSlots = []; + (window as TestWindow).tsjs!.adInit!(); + expect(destroySlots).not.toHaveBeenCalled(); + }); + + it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { + type FakeSlot = { + addService(service: unknown): FakeSlot; + setTargeting(key: string, value: string | string[]): FakeSlot; + getSlotElementId(): string; + getTargeting(key?: string): string[]; + }; + const slots = new Map(); + const requests: string[] = []; + let initialLoadDisabled = false; + const makeSlot = (elementId: string): FakeSlot => ({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue(elementId), + getTargeting: vi.fn().mockReturnValue([]), + }); + const pubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn(() => Array.from(slots.values())), + addEventListener: vi.fn(), + refresh: vi.fn((requestedSlots?: FakeSlot[]) => { + (requestedSlots ?? Array.from(slots.values())).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }), + disableInitialLoad: vi.fn(() => { + initialLoadDisabled = true; + }), + }; + const nativeDefineSlot = vi.fn( + (_adUnitPath: string, _formats: number[][], elementId: string) => { + const slot = makeSlot(elementId); + slots.set(elementId, slot); + return slot; + } + ); + const nativeDisplay = vi.fn((elementId: string) => { + if (!initialLoadDisabled) requests.push(elementId); + }); + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: nativeDefineSlot, + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(pubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + pubads.disableInitialLoad(); + (window as TestWindow).tsjs!.adInit!(); + + const publisherDefineSlot = googletag.defineSlot as unknown as ( + adUnitPath: string, + formats: number[][], + elementId: string + ) => FakeSlot; + const publisherDisplay = googletag.display as unknown as (elementId: string) => void; + const publisherRefresh = pubads.refresh as unknown as () => void; + const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); + publisherSlot.addService(pubads); + publisherDisplay('div-atf-sidebar'); + slots.set('div-unrelated', makeSlot('div-unrelated')); + publisherRefresh(); + + expect(nativeDefineSlot).toHaveBeenCalledTimes(1); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests.filter((elementId) => elementId === 'div-atf-sidebar')).toHaveLength(1); + expect(requests).toContain('div-unrelated'); }); it('refreshes TS-defined slots when the publisher disabled GPT initial load', async () => { @@ -197,12 +362,13 @@ describe('installTsAdInit', () => { getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), getTargeting: vi.fn().mockReturnValue([]), }; + const nativeRefresh = vi.fn(); const mockPubads = { enableSingleRequest: vi.fn(), // Publisher has not defined this slot, so TS defines (owns) it. getSlots: vi.fn().mockReturnValue([]), addEventListener: vi.fn(), - refresh: vi.fn(), + refresh: nativeRefresh, disableInitialLoad: vi.fn(), }; const displayMock = vi.fn(); @@ -240,7 +406,7 @@ describe('installTsAdInit', () => { // The slot is still registered via display(), and additionally refreshed so // it actually requests an ad under disableInitialLoad(). expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); }); it('sets adInitRefreshInProgress only for the duration of the internal refresh', async () => { diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md new file mode 100644 index 000000000..4699e3c42 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -0,0 +1,219 @@ +# Prevent Duplicate GPT Slot Requests — Implementation Plan + +> **Status:** Implemented locally; production-like browser validation remains pending. +> +> **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` + +**Goal:** Ensure one GPT slot and one initial request per configured placement when +TS `adInit()` runs before a publisher later defines the placement's inner GPT div. + +**Architecture:** TS creates its fallback on the resolved inner div and records a +handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and +`pubads().refresh` alias a matching late publisher definition to that slot and +suppress only the duplicate initial publisher request. A successful handoff transfers +SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle +share this runtime protocol through `window.tsjs`. + +**Primary files:** + +- `crates/trusted-server-js/lib/src/core/types.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- `crates/trusted-server-core/src/integrations/gpt.rs` + +## Preconditions + +- [ ] Confirm with the issue owner that the intended late-owner behavior is slot + handoff (publisher receives the existing inner-div slot), not a hydration-delay + policy. +- [ ] Capture representative publisher call sequences for normal initial load and + `disableInitialLoad()` before changing wrappers. The expected sequence is + `defineSlot` → `addService` → `display`; initial-load-disabled pages additionally + call `refresh`. +- [ ] Establish an automated fake-GPT request counter: calling native `display` with + initial load enabled, or native `refresh` with initial load disabled, records a + request. Assertions must use this counter rather than only `getSlots()`. + +## Task 1: Add the shared handoff state and typed GPT wrapper surface + +**Files:** + +- Modify `crates/trusted-server-js/lib/src/core/types.ts` +- Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must + retain serializable lifecycle flags: TS-created, ownership-transferred, initial + request made, and one-shot publisher display/refresh suppression state. +- [ ] Add only the minimal optional/internal type surface needed for idempotence + markers on GPT functions and `pubads`. Do not weaken the public GPT types with + `any`. +- [ ] Add helper functions in `index.ts` to: + - find a live GPT slot by exact element ID; + - register and retrieve a claim; + - remove a transferred slot from `ts.prevGptSlots`; + - run an internal TS GPT call behind a short-lived guard; + - filter a requested refresh list (including no-argument/global refresh) by the + entries whose one-shot publisher refresh must be suppressed. +- [ ] Keep the registry on `window.tsjs`, not in module scope, so the bootstrap state + survives bundle loading. + +**Focused checks:** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index.test.ts +``` + +## Task 2: Install scoped idempotent handoff wrappers + +**File:** `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] From the GPT command queue, install wrappers once GPT exposes the real methods. + Mark the wrapped functions/service so a later `installTsAdInit()` call or the + bootstrap-to-bundle handoff cannot stack wrappers. +- [ ] `defineSlot` wrapper: + - pass through TS-internal calls and IDs absent from the registry; + - for a late publisher call on a claimed inner div, find and return the existing + slot without calling native `defineSlot`; + - mark ownership transferred and remove that slot from `prevGptSlots` before + returning it; + - log, but do not create a second slot, if publisher arguments differ from the TS + configuration. +- [ ] `display` wrapper: consume the one permitted publisher post-handoff display + call without invoking native `display`; pass every other call through unchanged. +- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted + post-handoff refresh for each claimed slot. If called with no slot list, expand + `getSlots()`, filter only the claimed slots, and forward the remaining slots + explicitly. Preserve all unrelated refreshes. +- [ ] Ensure wrapper installation precedes the fallback definition path and does not + change existing publisher-owned-slot behavior. + +**Focused checks:** + +```bash +cd crates/trusted-server-js/lib +npx vitest run test/integrations/gpt/ad_init.test.ts +``` + +## Task 3: Change fallback creation to the actual inner div + +**File:** `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` + +- [ ] Delete the `${actualDivId}-container` fallback selection. When no existing + publisher slot is found, call `defineSlot` with `actualDivId`. +- [ ] Register the handoff claim immediately after successful TS definition. +- [ ] Keep `display()` for TS-created slots; with initial load disabled, retain the + single TS `refresh()` that makes the required initial request. +- [ ] Simplify `divToSlotId` and `prevSlotTargetingKeys` to the actual inner div; + remove only mappings that existed exclusively for the container fallback. +- [ ] On SPA navigation, destroy only claims that remain TS-owned. A transferred + claim must participate in stale-targeting cleanup but never be passed to + `destroySlots()`. +- [ ] Retain exact match then prefix-based dynamic-ID lookup; do not interpolate + publisher-provided IDs into CSS selectors. + +## Task 4: Add request-level regression coverage for the full bundle + +**Files:** + +- Modify `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` if the + shared wrapper setup belongs there + +- [ ] Introduce a reusable fake GPT fixture that models slots by element ID and + records native `defineSlot`, `display`, `refresh`, and request events. Its + `getSlots()` result must update when a slot is defined so the test cannot pass by + asserting a stale static array. +- [ ] Add a failing regression test for the critical sequence: + 1. TS finds the inner div and runs `adInit()` before publisher setup; + 2. TS defines/displays the inner div and makes one request; + 3. publisher calls `defineSlot(innerDiv).addService(...); display(innerDiv)`; + 4. assert native `defineSlot` was called once, there is one slot, and there is one + request. +- [ ] Add the same sequence with `disableInitialLoad()`: TS display plus its refresh + makes one request; the publisher's first refresh cannot make a second request. +- [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert + the claimed slot is suppressed once and the unrelated slot is refreshed. +- [ ] Add an already publisher-owned test proving TS does not install a claim, applies + targeting, and refreshes that slot. +- [ ] Add a no-publisher test proving TS still creates, displays, and requests its + inner-div slot exactly once. +- [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not + destroy the transferred slot, clears old TS keys, and reapplies current-route + targeting. +- [ ] Retain or extend the dynamic prefix-ID test to prove a resolved runtime ID is + the handoff key. + +## Task 5: Mirror the runtime protocol in the head bootstrap + +**Files:** + +- Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify `crates/trusted-server-core/src/integrations/gpt.rs` + +- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and + idempotence markers to the plain-JavaScript bootstrap. +- [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can + adopt the initial claim after the bundle loads. +- [ ] Ensure its internal definition/display/refresh calls use the same guards as the + bundle; bootstrap must not transfer or suppress its own operations. +- [ ] Extend the `gpt.rs` head-insert tests to assert that the bootstrap contains the + inner-div handoff protocol and no longer contains the container fallback. +- [ ] Add an executable bootstrap behavior test if practical by evaluating the + injected script against the same fake GPT fixture. If the test setup cannot execute + the included asset without duplication, record that limitation and keep the Rust + source-contract assertion plus identical bundle lifecycle tests as the minimum + coverage. + +## Task 6: Validate, inspect, and ship + +- [ ] Run focused request-level tests: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index.test.ts + ``` + +- [ ] Run all TSJS tests and formatting: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run + npm run format + ``` + +- [ ] Run the target-matched Rust tests that cover the embedded bootstrap, followed by + project formatting and linting: + + ```bash + cargo test-axum + cargo fmt --all -- --check + cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare + ``` + +- [ ] Before PR handoff, run the full required CI gates from `CLAUDE.md`, including + Fastly, Axum, Cloudflare, Spin, integration parity, JS build/tests/format, and docs + format. +- [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any + use of container IDs in GPT slot creation. +- [ ] In a controlled production-like browser capture, verify one initial request for + each affected visible placement and independently verify an unrelated placement + remains requestable. +- [ ] Update issue #944 with the ownership-handoff decision, test evidence, and + browser-capture result. + +## Stop conditions + +Stop and return to design review instead of adding heuristics if any of these occur: + +- A publisher relies on a late `defineSlot` with materially different path or size + arguments and cannot accept the existing TS slot. +- The publisher's first initial-load-disabled refresh cannot be identified without + suppressing unrelated legitimate refreshes. +- A cross-bundle bootstrap handoff requires module-local identity that cannot be + represented safely through `window.tsjs`. +- Browser validation shows a second request despite native `defineSlot`/`display`/ + `refresh` suppression; capture the GPT event ordering before choosing another + strategy. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md new file mode 100644 index 000000000..770718199 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -0,0 +1,165 @@ +# Prevent Duplicate GPT Slot Requests — Design Specification + +## Problem + +When `tsjs.adInit()` executes before a publisher's framework later calls +`googletag.defineSlot()` for the same placement, TS currently defines and displays a +slot on the outer `-container` element. The publisher subsequently defines and +displays an inner-div slot. These are distinct GPT slots, so they make separate GAM +requests for one visible placement. + +The affected paths are deliberately duplicated today: + +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle + implementation used after the TSJS bundle loads. +- `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` is the head-injected + implementation that can make the initial request before the bundle loads. + +A fix must keep both implementations in sync. + +## Goals + +1. A configured placement has at most one initial GPT slot and ad request when TS + runs before a publisher defines its inner div. +2. Apply TS targeting and the `ts_initial=1` marker before that single initial + request. +3. Continue reusing a slot that the publisher has already defined. +4. Keep the TS-only fallback: if the publisher never defines the placement, TS still + displays it and makes exactly one initial request. +5. Preserve `disableInitialLoad()`, SPA targeting cleanup, and the rule that TS does + not destroy genuinely publisher-owned slots. +6. Keep dynamic div-ID prefix resolution intact. + +## Non-goals + +- Deduplicating by GAM ad-unit path. Multiple visible placements may validly share a + path. +- Changing publisher GAM configuration, line items, or refresh policy. +- Delaying the initial TS request while waiting an arbitrary amount of time for + framework hydration. A time-based grace period cannot distinguish a slow + publisher-owned slot from a placement that the publisher will never define. +- General interception of unrelated GPT slots. + +## Decision: one inner-div slot with late-definition handoff + +TS will define its fallback slot on the **actual inner div**, never on its outer +`-container` element. It will record a narrowly scoped handoff claim keyed by that +inner div ID. A `googletag.defineSlot` wrapper then recognizes a later publisher +request for that exact div and returns the existing TS slot rather than invoking +GPT's native `defineSlot` again. + +GPT requires a one-to-one slot-to-div relationship and documents that a slot should +be displayed only once. Sharing the initial inner-div slot therefore avoids both the +competing container slot and an invalid duplicate definition. + +### Lifecycle + +1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner + div. TS applies targeting, records it as publisher-owned, and refreshes it as it + does today. +2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, + enables services when needed, and displays it. When initial load is disabled, TS + performs its existing one explicit refresh. TS records this slot as TS-owned and + handoff-eligible. +3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded + inner-div claim, returns the existing slot, and transfers ownership: it removes + the slot from TS's future `destroySlots()` set. The publisher's setup continues + against that same slot. +4. **Publisher's first request call** — the wrapper suppresses the duplicate + publisher `display()` call. With `disableInitialLoad()`, it instead suppresses + only the publisher's first refresh for the transferred slot, because TS has + already issued the required initial refresh. For a no-argument/global refresh, + the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, + and forward the remaining slots explicitly so unrelated slots still refresh. +5. **Later refreshes and SPA navigation** — after the one-shot suppression is + consumed, publisher refreshes are untouched. On navigation, TS clears its + targeting from the shared slot and may reuse it for the next route; it must not + destroy a slot after ownership has transferred. + +The wrapper is not a global deduplicator. It only handles IDs present in TS's +handoff registry and must preserve native `defineSlot`, `display`, and `refresh` +behavior for every other placement. + +## Implementation shape + +### Shared runtime state + +Add a small, serializable `window.tsjs` registry that both initial implementations +can read after the bundle replaces the bootstrap implementation. It is keyed by the +resolved actual div ID and records at least: + +- whether TS created the slot and whether ownership has transferred; +- whether one publisher `display()` or initial-load-disabled `refresh()` remains to + suppress. + +Do not rely only on module-local state: the bootstrap can define the initial slot +before `index.ts` is loaded. Look up the live slot by element ID through +`pubads().getSlots()` when a wrapper needs it. + +Install idempotent markers on the wrapped GPT functions/services so the bootstrap and +bundle do not stack wrappers. Each wrapper must retain and call the original bound +function for non-claimed slots. Internal TS calls need a short-lived guard so the +wrappers do not mistake TS's own `defineSlot`, `display`, or `refresh` for a +publisher handoff. + +### Full bundle + +In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: + +- Replace the container fallback with `actualDivId`. +- Add the typed handoff-registry state to `TsjsApi` in + `crates/trusted-server-js/lib/src/core/types.ts`. +- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff + wrappers from the GPT command queue before `adInit()` can create a fallback slot. +- When a late publisher definition is aliased to the existing slot, remove it from + `prevGptSlots` and mark it transferred before returning it. +- Keep targeting cleanup keyed by the real inner div. Remove the old dual + inner/container mappings because the slot element ID is now the inner div. + +### Head bootstrap + +Mirror the same ownership registry and wrappers in +`crates/trusted-server-core/src/integrations/gpt_bootstrap.js`. The bootstrap must +leave the registry and idempotence markers in `window.tsjs` so the full bundle adopts +rather than re-wraps or reclaims the initial slot. + +This duplication is intentional for now: the head bootstrap is needed to apply +server-side targeting before the normal bundle becomes available. The regression +suite must exercise both implementations' observable contract. + +## Compatibility rules and risks + +| Risk | Mitigation | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | +| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | +| Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | +| Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | +| Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | +| A framework creates the inner element only after `adInit()` | TS still skips an absent element, as it does today; when the publisher owns that later-created slot it will not be duplicated. Supplying TS targeting to such a slot is a separate readiness problem, not part of this duplicate-request fix. | + +## Acceptance criteria + +- A late `defineSlot(innerDiv)` aliases the already-created inner-div TS slot; native + `defineSlot` is not called a second time for that placement. +- Request instrumentation records one initial request for the placement in normal and + initial-load-disabled modes. +- The late publisher `display()` (and its first initial-load-disabled refresh) cannot + create a second request, while unrelated slots retain their normal calls. +- Existing publisher slots are still reused and receive TS targeting. +- A slot that no publisher claims is displayed and requested once by TS. +- A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is + still cleared and reapplied correctly on the next route. +- Dynamic resolved div IDs work without constructing a CSS selector from the ID. +- Bootstrap and bundle paths pass the same ownership/request assertions. + +## Validation + +1. Add focused Vitest lifecycle tests with a fake GPT that records native + `defineSlot`, `display`, `refresh`, and synthetic request events. +2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. +3. Run the target-matched Rust test suite so the included bootstrap and its source + assertions compile and pass. +4. In a controlled browser capture, verify that one configured header and one + configured fixed placement each produce one initial slot request, while a distinct + in-content placement remains independently requestable. From b65e1aedd33440a281cf28443c0ffd6e46b9de02 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 14:14:54 -0500 Subject: [PATCH 120/494] Gate publisher GPT requests until targeting is ready --- .../src/integrations/gpt.rs | 7 +- .../src/integrations/gpt_bootstrap.js | 108 +++++++++++--- .../trusted-server-js/lib/src/core/types.ts | 11 ++ .../lib/src/integrations/gpt/index.ts | 133 ++++++++++++----- .../lib/test/integrations/gpt/ad_init.test.ts | 134 +++++++++++++++++- ...-24-prevent-duplicate-gpt-slot-requests.md | 50 ++++--- ...vent-duplicate-gpt-slot-requests-design.md | 60 +++++--- 7 files changed, 398 insertions(+), 105 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index f53a21cf0..20e4a9492 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -474,7 +474,8 @@ impl IntegrationHeadInjector for GptIntegration { /// ## Scroll / refresh handoff contract (Phase 1) /// /// `tsjs.adInit` handles **initial render only**: it wires server-side bid - /// targeting into GPT slots and refreshes them. Win/billing beacons fire + /// targeting into GPT slots and replays only publisher requests held until + /// that targeting was available. Win/billing beacons fire /// only from the TS render bridge in the JS bundle, where a matching /// Prebid Universal Creative request proves the TS creative rendered. /// It does **not** trigger refresh auctions or handle GPT slot refresh events. @@ -1241,6 +1242,10 @@ mod tests { combined.contains("__tsSlotHandoffPatched"), "bootstrap should install idempotent GPT handoff wrappers" ); + assert!( + combined.contains("gptInitialRequestGate") && combined.contains("pendingDisplays"), + "bootstrap should hold configured publisher requests until initial targeting is applied" + ); assert!( combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), "bootstrap should define the TS fallback on the actual inner div" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 0c2697357..257da5908 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -10,8 +10,8 @@ // - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. -// - `refresh()` is called only for the slots defined in this pass, -// never the global slot list. +// - `refresh()` is called only for TS-defined slots in this pass and +// publisher requests the initial gate held, never the global slot list. // // Only installed if `window.tsjs.adInit` isn't already defined. (function () { @@ -51,6 +51,45 @@ ); } + function configuredSlotForElementId(elementId) { + return (ts.adSlots || []).find(function (slot) { + return ( + slot.div_id && + (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && + !elementId.endsWith("-container") + ); + }); + } + + function initialRequestGate() { + if (!ts.gptInitialRequestGate) { + ts.gptInitialRequestGate = { + pendingDisplays: {}, + pendingRefreshes: {}, + released: false, + }; + } + return ts.gptInitialRequestGate; + } + + function takeInitialPublisherRequests(pubads) { + var gate = initialRequestGate(); + if (gate.released) return { displayIds: [], refreshSlots: [] }; + + gate.released = true; + var displayIds = Object.keys(gate.pendingDisplays); + var refreshIds = Object.keys(gate.pendingRefreshes); + gate.pendingDisplays = {}; + gate.pendingRefreshes = {}; + var slots = pubads.getSlots ? pubads.getSlots() : []; + return { + displayIds: displayIds, + refreshSlots: slots.filter(function (slot) { + return refreshIds.includes(slot.getSlotElementId()); + }), + }; + } + function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -121,6 +160,15 @@ handoff.suppressPublisherDisplay = false; return; } + var gate = initialRequestGate(); + if ( + !ts.gptSlotHandoffInternal && + !gate.released && + configuredSlotForElementId(elementId) + ) { + gate.pendingDisplays[elementId] = true; + return; + } originalDisplay(elementId); }; patchedDisplay.__tsSlotHandoffPatched = true; @@ -141,13 +189,22 @@ return; } var suppressed = false; + var gate = initialRequestGate(); var remainingSlots = slots.filter(function (slot) { var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (!handoff || !handoff.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; + if (handoff && handoff.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + } + var elementId = slot.getSlotElementId(); + if (!gate.released && configuredSlotForElementId(elementId)) { + gate.pendingRefreshes[elementId] = true; + suppressed = true; + return false; + } + return true; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -172,13 +229,15 @@ // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. + // Publisher-owned slots can be refreshed on SPA navigation. On initial + // load their first request is held until the targeting below is applied. var slotsToRefresh = []; + var isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself. GPT requires display() to // register/render a freshly-defined slot; refresh() alone no-ops for a // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; + var hasAppliedTargeting = false; slots.forEach(function (slot) { // Resolve actual div ID: exact match first, then safe prefix scan. // div_id in config may be a stable prefix (e.g. "ad-header-0-") when @@ -245,6 +304,7 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); + hasAppliedTargeting = true; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -257,34 +317,36 @@ newSlots.push(s); var displayId = s.getSlotElementId() || actualDivId; slotsToDisplay.push(displayId); - } else { + } else if (!isInitialAdInit) { slotsToRefresh.push(s); } }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { + var heldPublisherRequests = isInitialAdInit + ? takeInitialPublisherRequests(googletag.pubads()) + : { displayIds: [], refreshSlots: [] }; + ts.gptInitialAdInitCompleted = true; + if (!ts.servicesEnabled && (hasAppliedTargeting || heldPublisherRequests.displayIds.length > 0 || heldPublisherRequests.refreshSlots.length > 0)) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot; without it the slot no-ops and misses its - // impression. Runs after enableServices(); on SPA navigation services are - // already enabled, so this runs unconditionally for new slots. - slotsToDisplay.forEach(function (divId) { + // Register/render TS-defined slots and replay publisher displays held + // before server-side bids were available. The replay is the publisher's + // one initial request, not a later TS refresh. + heldPublisherRequests.displayIds.concat(slotsToDisplay).forEach(function (divId) { runHandoffInternal(function () { googletag.display(divId); }); }); - // Reused publisher-owned slots always need a refresh to pick up the - // server-side targeting. TS-defined slots are fetched by display() above - // unless the publisher disabled initial load, in which case display() only - // registers them and refresh() must request the ad — otherwise they render - // blank. Only add them in that case to avoid double-requesting. - var slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; + // Replay held publisher refreshes after targeting. On SPA navigation TS + // refreshes reused publisher slots as before; TS-defined slots need a + // refresh only when initial load was disabled. + var slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( + slotsToRefresh, + ts.gptInitialLoadDisabled ? newSlots : [], + ); if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index cd25133d1..2ced11086 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -77,6 +77,13 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +/** Publisher requests held until initial TS targeting has been applied. */ +export interface GptInitialRequestGate { + pendingDisplays: Record; + pendingRefreshes: Record; + released: boolean; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -137,6 +144,10 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ gptSlotHandoffs?: Record; + /** Publisher initial requests held until TS has applied server-side targeting. */ + gptInitialRequestGate?: GptInitialRequestGate; + /** True after the first page-load `adInit()` has handled publisher slots. */ + gptInitialAdInitCompleted?: boolean; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 8853997c3..effae7ada 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,5 +1,11 @@ import { log } from '../../core/log'; -import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; +import type { + AuctionSlot, + AuctionBidData, + GptInitialRequestGate, + GptSlotHandoff, + TsjsApi, +} from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -460,6 +466,41 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } +function configuredSlotForElementId(ts: TsjsApi, elementId: string): AuctionSlot | undefined { + return ts.adSlots?.find( + (slot) => + !!slot.div_id && + (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && + !elementId.endsWith('-container') + ); +} + +function initialRequestGate(ts: TsjsApi): GptInitialRequestGate { + return (ts.gptInitialRequestGate ??= { + pendingDisplays: {}, + pendingRefreshes: {}, + released: false, + }); +} + +function takeInitialPublisherRequests( + ts: TsjsApi, + pubads: GoogleTagPubAdsService +): { displayIds: string[]; refreshSlots: GoogleTagSlot[] } { + const gate = initialRequestGate(ts); + if (gate.released) return { displayIds: [], refreshSlots: [] }; + + gate.released = true; + const displayIds = Object.keys(gate.pendingDisplays); + const refreshIds = new Set(Object.keys(gate.pendingRefreshes)); + gate.pendingDisplays = {}; + gate.pendingRefreshes = {}; + const refreshSlots = (pubads.getSlots?.() ?? []).filter((slot) => + refreshIds.has(slot.getSlotElementId()) + ); + return { displayIds, refreshSlots }; +} + function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -537,6 +578,15 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { handoff.suppressPublisherDisplay = false; return; } + const gate = initialRequestGate(ts); + if ( + !ts.gptSlotHandoffInternal && + !gate.released && + configuredSlotForElementId(ts, elementId) + ) { + gate.pendingDisplays[elementId] = true; + return; + } originalDisplay(elementId); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -559,12 +609,21 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { } let suppressed = false; + const gate = initialRequestGate(ts); const remainingSlots = slots.filter((slot) => { const handoff = handoffForSlot(ts, slot); - if (!handoff?.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; + if (handoff?.suppressPublisherRefresh) { + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; + } + const elementId = slot.getSlotElementId(); + if (!gate.released && configuredSlotForElementId(ts, elementId)) { + gate.pendingRefreshes[elementId] = true; + suppressed = true; + return false; + } + return true; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -601,14 +660,17 @@ export function installTsAdInit(): void { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. + // Publisher-owned slots can be refreshed on SPA navigation. On initial + // load their first request is held by the head-installed gate and replayed + // only after the targeting below has been applied. const slotsToRefresh: GoogleTagSlot[] = []; + const isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself this call. GPT requires a // display() call to register/render a freshly-defined slot; refresh() // alone no-ops for a slot that was never displayed, so these are // display()ed instead of refreshed. const slotsToDisplay: string[] = []; + let hasAppliedTargeting = false; const divToSlotId: Record = {}; const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; const nextSlotTargetingKeys: Record = {}; @@ -682,6 +744,7 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); + hasAppliedTargeting = true; // Map the resolved inner div to the slot ID so slotRenderEnded and ADM // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; @@ -692,7 +755,7 @@ export function installTsAdInit(): void { if (tsOwned) { newSlots.push(gptSlot); slotsToDisplay.push(slotDivId2); - } else { + } else if (!isInitialAdInit) { slotsToRefresh.push(gptSlot); } @@ -709,11 +772,20 @@ export function installTsAdInit(): void { // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - - // Whether this call produced any TS slot to render. A gated page-bids - // response (auction kill switch or consent denial) returns no slots, so - // the loops above leave these empty. - const hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; + const heldPublisherRequests = isInitialAdInit + ? takeInitialPublisherRequests(ts, g.pubads!()) + : { displayIds: [], refreshSlots: [] }; + ts.gptInitialAdInitCompleted = true; + + // Whether this call produced a request to make. A gated page-bids response + // (auction kill switch or consent denial) returns no slots, so the loops + // above leave these empty. + const hasRenderableWork = + slotsToDisplay.length > 0 || + slotsToRefresh.length > 0 || + heldPublisherRequests.displayIds.length > 0 || + heldPublisherRequests.refreshSlots.length > 0 || + hasAppliedTargeting; // enableSingleRequest and enableServices must only be called once per page // load. Skip activating GPT services when TS has nothing to display or @@ -742,25 +814,22 @@ export function installTsAdInit(): void { }); } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot — without it the slot no-ops ("defineSlot was - // called without a matching display call") and misses its impression. - // Must run after enableServices(); on SPA navigation services are already - // enabled, so this runs unconditionally for any newly-defined slots. - slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); - - // Slots needing an explicit ad request via refresh(). Reused - // publisher-owned slots always need one to pick up the just-applied - // server-side targeting. TS-defined slots are normally fetched by the - // display() above — but when the publisher called - // pubads().disableInitialLoad(), display() only registers the slot and the - // ad request must come from refresh(). Without this, a TS-owned - // first-impression slot renders blank on initial-load-disabled pages. Only - // add them in that case; otherwise display() + refresh() would - // double-request the impression. - const slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; + // Register/render TS-defined slots and replay publisher displays held + // before the server-side bids were available. The gate is released only + // after targeting has been applied, so this remains the publisher's one + // initial request rather than a later TS refresh. + heldPublisherRequests.displayIds + .concat(slotsToDisplay) + .forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + + // Slots needing an explicit ad request via refresh(). Publisher refreshes + // held on the initial page load are replayed after targeting. On SPA + // navigation TS refreshes reused publisher slots as before. TS-defined + // slots need a refresh only when the publisher disabled initial load. + const slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( + slotsToRefresh, + ts.gptInitialLoadDisabled ? newSlots : [] + ); if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index f99d90fa7..bdfc7123b 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -77,7 +77,7 @@ describe('installTsAdInit', () => { document.getElementById("ad'prefix-real")?.remove(); }); - it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { + it('reads window.tsjs.bids synchronously without re-requesting an existing publisher slot', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -133,11 +133,130 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); + it('holds and replays a publisher display once after applying initial targeting', async () => { + const requests: string[] = []; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); + const nativeRefresh = vi.fn(); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + + googletag.display('div-atf-sidebar'); + expect(nativeDisplay).not.toHaveBeenCalled(); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(requests).toEqual(['div-atf-sidebar']); + expect(nativeRefresh).not.toHaveBeenCalled(); + }); + + it('holds and replays a disabled-load publisher refresh once after targeting', async () => { + const requests: string[] = []; + let initialLoadDisabled = false; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const unrelatedSlot = { + getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), + }; + const nativeDisplay = vi.fn((elementId: string) => { + if (!initialLoadDisabled) requests.push(elementId); + }); + const nativeRefresh = vi.fn((slots?: Array) => { + (slots ?? [mockSlot, unrelatedSlot]).forEach((slot) => + requests.push(slot.getSlotElementId()) + ); + }); + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot, unrelatedSlot]), + addEventListener: vi.fn(), + refresh: nativeRefresh, + disableInitialLoad: vi.fn(() => { + initialLoadDisabled = true; + }), + }; + const googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + display: nativeDisplay, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).googletag = googletag; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + mockPubads.disableInitialLoad(); + googletag.display('div-atf-sidebar'); + mockPubads.refresh(); + expect(nativeDisplay).not.toHaveBeenCalled(); + expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); + + (window as TestWindow).tsjs!.adInit!(); + + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(nativeDisplay).toHaveBeenCalledTimes(1); + expect(nativeRefresh).toHaveBeenCalledTimes(2); + expect(nativeRefresh).toHaveBeenLastCalledWith([mockSlot]); + expect(requests).toEqual(['div-unrelated', 'div-atf-sidebar']); + }); + it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -444,6 +563,9 @@ describe('installTsAdInit', () => { }, ], bids: {}, + // This models a route update: existing publisher slots are refreshed on + // SPA navigation, while initial-load publisher slots are not re-requested. + gptInitialAdInitCompleted: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -589,7 +711,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { @@ -914,7 +1036,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).apstag; }); - it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { + it('does not re-request an existing publisher slot when tsjs.bids is empty', async () => { const emptyTestSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -953,7 +1075,7 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).toHaveBeenCalled(); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { @@ -996,7 +1118,7 @@ describe('installTsAdInit', () => { installTsAdInit(); expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); + expect(mockPubads.refresh).not.toHaveBeenCalled(); }); }); diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 4699e3c42..24879c8e4 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -1,6 +1,7 @@ # Prevent Duplicate GPT Slot Requests — Implementation Plan -> **Status:** Implemented locally; production-like browser validation remains pending. +> **Status:** Revised after production-like validation found a second request for +> publisher-owned slots when hydration-safe scheduling defers `adInit()`. > > **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` @@ -8,10 +9,11 @@ TS `adInit()` runs before a publisher later defines the placement's inner GPT div. **Architecture:** TS creates its fallback on the resolved inner div and records a -handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and -`pubads().refresh` alias a matching late publisher definition to that slot and -suppress only the duplicate initial publisher request. A successful handoff transfers -SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle +handoff claim. Narrow, idempotent GPT wrappers also gate a configured publisher +slot's first `display`/`refresh` while the server auction result is unavailable. At +`adInit()`, TS applies targeting to that same publisher slot and replays the held +native request once; it does not issue a second TS refresh. Late-definition handoff +and SPA ownership transfer remain unchanged. The head bootstrap and full TSJS bundle share this runtime protocol through `window.tsjs`. **Primary files:** @@ -43,9 +45,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/core/types.ts` - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` -- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must - retain serializable lifecycle flags: TS-created, ownership-transferred, initial - request made, and one-shot publisher display/refresh suppression state. +- [ ] Add `TsjsApi` state for both the div-ID-keyed late-handoff registry and an + initial publisher-request gate. The gate records held display/refresh IDs and + a released marker so it applies only once per page load. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -81,14 +83,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index returning it; - log, but do not create a second slot, if publisher arguments differ from the TS configuration. -- [ ] `display` wrapper: consume the one permitted publisher post-handoff display - call without invoking native `display`; pass every other call through unchanged. -- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted - post-handoff refresh for each claimed slot. If called with no slot list, expand - `getSlots()`, filter only the claimed slots, and forward the remaining slots - explicitly. Preserve all unrelated refreshes. -- [ ] Ensure wrapper installation precedes the fallback definition path and does not - change existing publisher-owned-slot behavior. +- [ ] `display` wrapper: consume the one permitted post-handoff display; before the + first `adInit()`, also hold a configured publisher slot's native display. +- [ ] `refresh` wrapper: consume one permitted post-handoff disabled-load refresh; + before the first `adInit()`, hold configured publisher refreshes and forward + all unrelated slots explicitly, including a no-argument/global refresh. +- [ ] At initial `adInit()`, apply targeting then replay held native calls; never + refresh an existing publisher-owned slot that has already requested. +- [ ] Ensure wrapper installation precedes publisher setup and fallback creation. **Focused checks:** @@ -136,8 +138,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts makes one request; the publisher's first refresh cannot make a second request. - [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert the claimed slot is suppressed once and the unrelated slot is refreshed. -- [ ] Add an already publisher-owned test proving TS does not install a claim, applies - targeting, and refreshes that slot. +- [ ] Add publisher-owned tests proving TS holds normal and disabled-load initial + requests, applies targeting, and replays exactly one native request. Also prove + an already-requested publisher slot is not refreshed again. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its inner-div slot exactly once. - [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not @@ -153,8 +156,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts - Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify `crates/trusted-server-core/src/integrations/gpt.rs` -- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and - idempotence markers to the plain-JavaScript bootstrap. +- [ ] Port the same initial-request gate, actual-inner-div fallback, registry names, + lifecycle flags, and idempotence markers to the plain-JavaScript bootstrap. - [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can adopt the initial claim after the bundle loads. - [ ] Ensure its internal definition/display/refresh calls use the same guards as the @@ -198,9 +201,10 @@ npx vitest run test/integrations/gpt/ad_init.test.ts format. - [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any use of container IDs in GPT slot creation. -- [ ] In a controlled production-like browser capture, verify one initial request for - each affected visible placement and independently verify an unrelated placement - remains requestable. +- [ ] In a controlled production-like browser capture with the hydration-safe + deferred `adInit()` path, verify one targeted initial request for each affected + visible placement and independently verify an unrelated placement remains + requestable. - [ ] Update issue #944 with the ownership-handoff decision, test evidence, and browser-capture result. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index 770718199..c94e25b2c 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -8,6 +8,12 @@ slot on the outer `-container` element. The publisher subsequently defines and displays an inner-div slot. These are distinct GPT slots, so they make separate GAM requests for one visible placement. +A production deployment also exposed the inverse ordering: the hydration-safe +body bootstrap delays `adInit()` until after `window.load`, so publisher code can +already have defined **and requested** its inner-div slot. In that ordering, +reusing the slot and refreshing it applies targeting too late and creates a second +SRA request. + The affected paths are deliberately duplicated today: - `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle @@ -40,7 +46,7 @@ A fix must keep both implementations in sync. publisher-owned slot from a placement that the publisher will never define. - General interception of unrelated GPT slots. -## Decision: one inner-div slot with late-definition handoff +## Decision: inner-div fallback, late-definition handoff, and an initial request gate TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that @@ -54,31 +60,36 @@ competing container slot and an invalid duplicate definition. ### Lifecycle -1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner - div. TS applies targeting, records it as publisher-owned, and refreshes it as it - does today. -2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, +1. **Publisher-owned before bids are available** — a scoped head-installed gate + holds the configured placement's first publisher `display()` or `refresh()`. + At `adInit()`, TS finds the publisher slot, applies targeting, and replays that + held native call exactly once. It never adds a second TS refresh. +2. **Already-requested publisher-owned slot** — if a configured publisher request + was not observed by the gate, TS applies targeting for later lifecycle work but + does not re-request the already-served initial impression. +3. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, enables services when needed, and displays it. When initial load is disabled, TS performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. -3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded +4. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded inner-div claim, returns the existing slot, and transfers ownership: it removes the slot from TS's future `destroySlots()` set. The publisher's setup continues against that same slot. -4. **Publisher's first request call** — the wrapper suppresses the duplicate +5. **Publisher's first request call after a late handoff** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has already issued the required initial refresh. For a no-argument/global refresh, the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, and forward the remaining slots explicitly so unrelated slots still refresh. -5. **Later refreshes and SPA navigation** — after the one-shot suppression is +6. **Later refreshes and SPA navigation** — after the one-shot suppression is consumed, publisher refreshes are untouched. On navigation, TS clears its targeting from the shared slot and may reuse it for the next route; it must not destroy a slot after ownership has transferred. -The wrapper is not a global deduplicator. It only handles IDs present in TS's -handoff registry and must preserve native `defineSlot`, `display`, and `refresh` -behavior for every other placement. +The wrappers are not global deduplicators. The initial request gate only holds the +first `display`/`refresh` for a configured placement until initial TS targeting is +available; handoff suppression only handles IDs present in TS's handoff registry. +All unrelated GPT calls retain native behavior. ## Implementation shape @@ -89,8 +100,10 @@ can read after the bundle replaces the bootstrap implementation. It is keyed by resolved actual div ID and records at least: - whether TS created the slot and whether ownership has transferred; -- whether one publisher `display()` or initial-load-disabled `refresh()` remains to - suppress. +- whether one post-handoff publisher `display()` or initial-load-disabled `refresh()` + remains to suppress; +- configured publisher displays and refreshes held before initial targeting, plus a + released marker so the gate applies only once per page load. Do not rely only on module-local state: the bootstrap can define the initial slot before `index.ts` is loaded. Look up the live slot by element ID through @@ -109,8 +122,12 @@ In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: - Replace the container fallback with `actualDivId`. - Add the typed handoff-registry state to `TsjsApi` in `crates/trusted-server-js/lib/src/core/types.ts`. -- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff - wrappers from the GPT command queue before `adInit()` can create a fallback slot. +- Install idempotent `defineSlot`, `display`, and `pubads().refresh` wrappers from + the GPT command queue before publisher setup. The latter two also hold the first + configured publisher request until `adInit()` has applied initial targeting. +- Replay held initial publisher displays/refreshes after targeting rather than + refreshing an existing publisher-owned slot. Retain reused-slot refreshes only for + later SPA navigations. - When a late publisher definition is aliased to the existing slot, remove it from `prevGptSlots` and mark it transferred before returning it. - Keep targeting cleanup keyed by the real inner div. Remove the old dual @@ -132,7 +149,7 @@ suite must exercise both implementations' observable contract. | Risk | Mitigation | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | -| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | +| Publisher invokes global `refresh()` before bids after `disableInitialLoad()` | Filter only configured held slots from the expanded list, forward unrelated slots immediately, then replay the held slots once after targeting. A no-argument refresh must not be silently dropped. | | Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | | Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | | Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | @@ -146,7 +163,9 @@ suite must exercise both implementations' observable contract. initial-load-disabled modes. - The late publisher `display()` (and its first initial-load-disabled refresh) cannot create a second request, while unrelated slots retain their normal calls. -- Existing publisher slots are still reused and receive TS targeting. +- A configured publisher slot whose first request occurs before the deferred + `adInit()` is held, receives TS targeting, and makes exactly one replayed native + request. An already-requested publisher slot is never re-requested by TS. - A slot that no publisher claims is displayed and requested once by TS. - A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is still cleared and reapplied correctly on the next route. @@ -160,6 +179,7 @@ suite must exercise both implementations' observable contract. 2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. 3. Run the target-matched Rust test suite so the included bootstrap and its source assertions compile and pass. -4. In a controlled browser capture, verify that one configured header and one - configured fixed placement each produce one initial slot request, while a distinct - in-content placement remains independently requestable. +4. In a controlled browser capture with deferred `adInit()`, verify that one + configured header and one configured fixed placement each produce one initial + slot request with TS targeting, while a distinct in-content placement remains + independently requestable. From f3c1e6bcbefcfc20144d874945d5277587612de1 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 16:17:31 -0500 Subject: [PATCH 121/494] Revert "Gate publisher GPT requests until targeting is ready" This reverts commit b65e1aedd33440a281cf28443c0ffd6e46b9de02. --- .../src/integrations/gpt.rs | 7 +- .../src/integrations/gpt_bootstrap.js | 108 +++----------- .../trusted-server-js/lib/src/core/types.ts | 11 -- .../lib/src/integrations/gpt/index.ts | 133 +++++------------ .../lib/test/integrations/gpt/ad_init.test.ts | 134 +----------------- ...-24-prevent-duplicate-gpt-slot-requests.md | 50 +++---- ...vent-duplicate-gpt-slot-requests-design.md | 60 +++----- 7 files changed, 105 insertions(+), 398 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 20e4a9492..f53a21cf0 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -474,8 +474,7 @@ impl IntegrationHeadInjector for GptIntegration { /// ## Scroll / refresh handoff contract (Phase 1) /// /// `tsjs.adInit` handles **initial render only**: it wires server-side bid - /// targeting into GPT slots and replays only publisher requests held until - /// that targeting was available. Win/billing beacons fire + /// targeting into GPT slots and refreshes them. Win/billing beacons fire /// only from the TS render bridge in the JS bundle, where a matching /// Prebid Universal Creative request proves the TS creative rendered. /// It does **not** trigger refresh auctions or handle GPT slot refresh events. @@ -1242,10 +1241,6 @@ mod tests { combined.contains("__tsSlotHandoffPatched"), "bootstrap should install idempotent GPT handoff wrappers" ); - assert!( - combined.contains("gptInitialRequestGate") && combined.contains("pendingDisplays"), - "bootstrap should hold configured publisher requests until initial targeting is applied" - ); assert!( combined.contains("return googletag.defineSlot") && combined.contains("actualDivId"), "bootstrap should define the TS fallback on the actual inner div" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 257da5908..0c2697357 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -10,8 +10,8 @@ // - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. -// - `refresh()` is called only for TS-defined slots in this pass and -// publisher requests the initial gate held, never the global slot list. +// - `refresh()` is called only for the slots defined in this pass, +// never the global slot list. // // Only installed if `window.tsjs.adInit` isn't already defined. (function () { @@ -51,45 +51,6 @@ ); } - function configuredSlotForElementId(elementId) { - return (ts.adSlots || []).find(function (slot) { - return ( - slot.div_id && - (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && - !elementId.endsWith("-container") - ); - }); - } - - function initialRequestGate() { - if (!ts.gptInitialRequestGate) { - ts.gptInitialRequestGate = { - pendingDisplays: {}, - pendingRefreshes: {}, - released: false, - }; - } - return ts.gptInitialRequestGate; - } - - function takeInitialPublisherRequests(pubads) { - var gate = initialRequestGate(); - if (gate.released) return { displayIds: [], refreshSlots: [] }; - - gate.released = true; - var displayIds = Object.keys(gate.pendingDisplays); - var refreshIds = Object.keys(gate.pendingRefreshes); - gate.pendingDisplays = {}; - gate.pendingRefreshes = {}; - var slots = pubads.getSlots ? pubads.getSlots() : []; - return { - displayIds: displayIds, - refreshSlots: slots.filter(function (slot) { - return refreshIds.includes(slot.getSlotElementId()); - }), - }; - } - function runHandoffInternal(callback) { var wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -160,15 +121,6 @@ handoff.suppressPublisherDisplay = false; return; } - var gate = initialRequestGate(); - if ( - !ts.gptSlotHandoffInternal && - !gate.released && - configuredSlotForElementId(elementId) - ) { - gate.pendingDisplays[elementId] = true; - return; - } originalDisplay(elementId); }; patchedDisplay.__tsSlotHandoffPatched = true; @@ -189,22 +141,13 @@ return; } var suppressed = false; - var gate = initialRequestGate(); var remainingSlots = slots.filter(function (slot) { var handoff = ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (handoff && handoff.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - } - var elementId = slot.getSlotElementId(); - if (!gate.released && configuredSlotForElementId(elementId)) { - gate.pendingRefreshes[elementId] = true; - suppressed = true; - return false; - } - return true; + if (!handoff || !handoff.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -229,15 +172,13 @@ // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. var newSlots = []; - // Publisher-owned slots can be refreshed on SPA navigation. On initial - // load their first request is held until the targeting below is applied. + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. var slotsToRefresh = []; - var isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself. GPT requires display() to // register/render a freshly-defined slot; refresh() alone no-ops for a // slot that was never displayed, so these are display()ed instead. var slotsToDisplay = []; - var hasAppliedTargeting = false; slots.forEach(function (slot) { // Resolve actual div ID: exact match first, then safe prefix scan. // div_id in config may be a stable prefix (e.g. "ad-header-0-") when @@ -304,7 +245,6 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - hasAppliedTargeting = true; // Map the resolved inner div to the slot ID. This bootstrap fires no // beacons and registers no slotRenderEnded listener; the map is consumed // by the bundle's render bridge (index.ts) once it loads. @@ -317,36 +257,34 @@ newSlots.push(s); var displayId = s.getSlotElementId() || actualDivId; slotsToDisplay.push(displayId); - } else if (!isInitialAdInit) { + } else { slotsToRefresh.push(s); } }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; - var heldPublisherRequests = isInitialAdInit - ? takeInitialPublisherRequests(googletag.pubads()) - : { displayIds: [], refreshSlots: [] }; - ts.gptInitialAdInitCompleted = true; - if (!ts.servicesEnabled && (hasAppliedTargeting || heldPublisherRequests.displayIds.length > 0 || heldPublisherRequests.refreshSlots.length > 0)) { + if (!ts.servicesEnabled) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; } - // Register/render TS-defined slots and replay publisher displays held - // before server-side bids were available. The replay is the publisher's - // one initial request, not a later TS refresh. - heldPublisherRequests.displayIds.concat(slotsToDisplay).forEach(function (divId) { + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot; without it the slot no-ops and misses its + // impression. Runs after enableServices(); on SPA navigation services are + // already enabled, so this runs unconditionally for new slots. + slotsToDisplay.forEach(function (divId) { runHandoffInternal(function () { googletag.display(divId); }); }); - // Replay held publisher refreshes after targeting. On SPA navigation TS - // refreshes reused publisher slots as before; TS-defined slots need a - // refresh only when initial load was disabled. - var slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( - slotsToRefresh, - ts.gptInitialLoadDisabled ? newSlots : [], - ); + // Reused publisher-owned slots always need a refresh to pick up the + // server-side targeting. TS-defined slots are fetched by display() above + // unless the publisher disabled initial load, in which case display() only + // registers them and refresh() must request the ad — otherwise they render + // blank. Only add them in that case to avoid double-requesting. + var slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has already wrapped diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 2ced11086..cd25133d1 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -77,13 +77,6 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } -/** Publisher requests held until initial TS targeting has been applied. */ -export interface GptInitialRequestGate { - pendingDisplays: Record; - pendingRefreshes: Record; - released: boolean; -} - export interface TsjsApi { version: string; que: Array<() => void>; @@ -144,10 +137,6 @@ export interface TsjsApi { gptInitialLoadDisabled?: boolean; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ gptSlotHandoffs?: Record; - /** Publisher initial requests held until TS has applied server-side targeting. */ - gptInitialRequestGate?: GptInitialRequestGate; - /** True after the first page-load `adInit()` has handled publisher slots. */ - gptInitialAdInitCompleted?: boolean; /** True only while TS calls a GPT function that the handoff wrappers observe. */ gptSlotHandoffInternal?: boolean; /** Guards SPA pushState hook installation. */ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index effae7ada..8853997c3 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1,11 +1,5 @@ import { log } from '../../core/log'; -import type { - AuctionSlot, - AuctionBidData, - GptInitialRequestGate, - GptSlotHandoff, - TsjsApi, -} from '../../core/types'; +import type { AuctionSlot, AuctionBidData, GptSlotHandoff, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -466,41 +460,6 @@ function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | unde return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } -function configuredSlotForElementId(ts: TsjsApi, elementId: string): AuctionSlot | undefined { - return ts.adSlots?.find( - (slot) => - !!slot.div_id && - (elementId === slot.div_id || elementId.startsWith(slot.div_id)) && - !elementId.endsWith('-container') - ); -} - -function initialRequestGate(ts: TsjsApi): GptInitialRequestGate { - return (ts.gptInitialRequestGate ??= { - pendingDisplays: {}, - pendingRefreshes: {}, - released: false, - }); -} - -function takeInitialPublisherRequests( - ts: TsjsApi, - pubads: GoogleTagPubAdsService -): { displayIds: string[]; refreshSlots: GoogleTagSlot[] } { - const gate = initialRequestGate(ts); - if (gate.released) return { displayIds: [], refreshSlots: [] }; - - gate.released = true; - const displayIds = Object.keys(gate.pendingDisplays); - const refreshIds = new Set(Object.keys(gate.pendingRefreshes)); - gate.pendingDisplays = {}; - gate.pendingRefreshes = {}; - const refreshSlots = (pubads.getSlots?.() ?? []).filter((slot) => - refreshIds.has(slot.getSlotElementId()) - ); - return { displayIds, refreshSlots }; -} - function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; @@ -578,15 +537,6 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { handoff.suppressPublisherDisplay = false; return; } - const gate = initialRequestGate(ts); - if ( - !ts.gptSlotHandoffInternal && - !gate.released && - configuredSlotForElementId(ts, elementId) - ) { - gate.pendingDisplays[elementId] = true; - return; - } originalDisplay(elementId); }; (patchedDisplay as HandoffPatchedFunction).__tsSlotHandoffPatched = true; @@ -609,21 +559,12 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { } let suppressed = false; - const gate = initialRequestGate(ts); const remainingSlots = slots.filter((slot) => { const handoff = handoffForSlot(ts, slot); - if (handoff?.suppressPublisherRefresh) { - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - } - const elementId = slot.getSlotElementId(); - if (!gate.released && configuredSlotForElementId(ts, elementId)) { - gate.pendingRefreshes[elementId] = true; - suppressed = true; - return false; - } - return true; + if (!handoff?.suppressPublisherRefresh) return true; + handoff.suppressPublisherRefresh = false; + suppressed = true; + return false; }); if (!suppressed) { originalRefresh(requestedSlots); @@ -660,17 +601,14 @@ export function installTsAdInit(): void { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. const newSlots: GoogleTagSlot[] = []; - // Publisher-owned slots can be refreshed on SPA navigation. On initial - // load their first request is held by the head-installed gate and replayed - // only after the targeting below has been applied. + // Publisher-owned slots TS reused — refreshed to pick up server-side + // targeting. The publisher already display()ed these. const slotsToRefresh: GoogleTagSlot[] = []; - const isInitialAdInit = !ts.gptInitialAdInitCompleted; // Element IDs of slots TS defined itself this call. GPT requires a // display() call to register/render a freshly-defined slot; refresh() // alone no-ops for a slot that was never displayed, so these are // display()ed instead of refreshed. const slotsToDisplay: string[] = []; - let hasAppliedTargeting = false; const divToSlotId: Record = {}; const prevSlotTargetingKeys = ts.prevSlotTargetingKeys ?? {}; const nextSlotTargetingKeys: Record = {}; @@ -744,7 +682,6 @@ export function installTsAdInit(): void { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); }); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); - hasAppliedTargeting = true; // Map the resolved inner div to the slot ID so slotRenderEnded and ADM // injection address the same, single GPT slot. divToSlotId[actualDivId] = slot.id; @@ -755,7 +692,7 @@ export function installTsAdInit(): void { if (tsOwned) { newSlots.push(gptSlot); slotsToDisplay.push(slotDivId2); - } else if (!isInitialAdInit) { + } else { slotsToRefresh.push(gptSlot); } @@ -772,20 +709,11 @@ export function installTsAdInit(): void { // Replace (not merge) so destroyed slots from previous navigation don't linger. ts.divToSlotId = divToSlotId; ts.prevSlotTargetingKeys = nextSlotTargetingKeys; - const heldPublisherRequests = isInitialAdInit - ? takeInitialPublisherRequests(ts, g.pubads!()) - : { displayIds: [], refreshSlots: [] }; - ts.gptInitialAdInitCompleted = true; - - // Whether this call produced a request to make. A gated page-bids response - // (auction kill switch or consent denial) returns no slots, so the loops - // above leave these empty. - const hasRenderableWork = - slotsToDisplay.length > 0 || - slotsToRefresh.length > 0 || - heldPublisherRequests.displayIds.length > 0 || - heldPublisherRequests.refreshSlots.length > 0 || - hasAppliedTargeting; + + // Whether this call produced any TS slot to render. A gated page-bids + // response (auction kill switch or consent denial) returns no slots, so + // the loops above leave these empty. + const hasRenderableWork = slotsToDisplay.length > 0 || slotsToRefresh.length > 0; // enableSingleRequest and enableServices must only be called once per page // load. Skip activating GPT services when TS has nothing to display or @@ -814,22 +742,25 @@ export function installTsAdInit(): void { }); } - // Register/render TS-defined slots and replay publisher displays held - // before the server-side bids were available. The gate is released only - // after targeting has been applied, so this remains the publisher's one - // initial request rather than a later TS refresh. - heldPublisherRequests.displayIds - .concat(slotsToDisplay) - .forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); - - // Slots needing an explicit ad request via refresh(). Publisher refreshes - // held on the initial page load are replayed after targeting. On SPA - // navigation TS refreshes reused publisher slots as before. TS-defined - // slots need a refresh only when the publisher disabled initial load. - const slotsNeedingRefresh = heldPublisherRequests.refreshSlots.concat( - slotsToRefresh, - ts.gptInitialLoadDisabled ? newSlots : [] - ); + // Register and render TS-defined slots. GPT requires display() for a + // freshly-defined slot — without it the slot no-ops ("defineSlot was + // called without a matching display call") and misses its impression. + // Must run after enableServices(); on SPA navigation services are already + // enabled, so this runs unconditionally for any newly-defined slots. + slotsToDisplay.forEach((divId) => withGptSlotHandoffInternal(ts, () => g.display?.(divId))); + + // Slots needing an explicit ad request via refresh(). Reused + // publisher-owned slots always need one to pick up the just-applied + // server-side targeting. TS-defined slots are normally fetched by the + // display() above — but when the publisher called + // pubads().disableInitialLoad(), display() only registers the slot and the + // ad request must come from refresh(). Without this, a TS-owned + // first-impression slot renders blank on initial-load-disabled pages. Only + // add them in that case; otherwise display() + refresh() would + // double-request the impression. + const slotsNeedingRefresh = ts.gptInitialLoadDisabled + ? slotsToRefresh.concat(newSlots) + : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { // One-shot bypass: this internal refresh delivers the just-applied diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index bdfc7123b..f99d90fa7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -77,7 +77,7 @@ describe('installTsAdInit', () => { document.getElementById("ad'prefix-real")?.remove(); }); - it('reads window.tsjs.bids synchronously without re-requesting an existing publisher slot', async () => { + it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -133,130 +133,11 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); }); - it('holds and replays a publisher display once after applying initial targeting', async () => { - const requests: string[] = []; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - googletag.display('div-atf-sidebar'); - expect(nativeDisplay).not.toHaveBeenCalled(); - - (window as TestWindow).tsjs!.adInit!(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['div-atf-sidebar']); - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('holds and replays a disabled-load publisher refresh once after targeting', async () => { - const requests: string[] = []; - let initialLoadDisabled = false; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeDisplay = vi.fn((elementId: string) => { - if (!initialLoadDisabled) requests.push(elementId); - }); - const nativeRefresh = vi.fn((slots?: Array) => { - (slots ?? [mockSlot, unrelatedSlot]).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot, unrelatedSlot]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(() => { - initialLoadDisabled = true; - }), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { atf_sidebar_ad: { hb_pb: '1.00' } }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - mockPubads.disableInitialLoad(); - googletag.display('div-atf-sidebar'); - mockPubads.refresh(); - expect(nativeDisplay).not.toHaveBeenCalled(); - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - - (window as TestWindow).tsjs!.adInit!(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(nativeRefresh).toHaveBeenCalledTimes(2); - expect(nativeRefresh).toHaveBeenLastCalledWith([mockSlot]); - expect(requests).toEqual(['div-unrelated', 'div-atf-sidebar']); - }); - it('displays TS-defined slots and does not include them in refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -563,9 +444,6 @@ describe('installTsAdInit', () => { }, ], bids: {}, - // This models a route update: existing publisher slots are refreshed on - // SPA navigation, while initial-load publisher slots are not re-requested. - gptInitialAdInitCompleted: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -711,7 +589,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); }); it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { @@ -1036,7 +914,7 @@ describe('installTsAdInit', () => { delete (window as TestWindow).apstag; }); - it('does not re-request an existing publisher slot when tsjs.bids is empty', async () => { + it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { const emptyTestSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -1075,7 +953,7 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalled(); }); it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { @@ -1118,7 +996,7 @@ describe('installTsAdInit', () => { installTsAdInit(); expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); + expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); }); }); diff --git a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md index 24879c8e4..4699e3c42 100644 --- a/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -1,7 +1,6 @@ # Prevent Duplicate GPT Slot Requests — Implementation Plan -> **Status:** Revised after production-like validation found a second request for -> publisher-owned slots when hydration-safe scheduling defers `adInit()`. +> **Status:** Implemented locally; production-like browser validation remains pending. > > **Spec:** `docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md` @@ -9,11 +8,10 @@ TS `adInit()` runs before a publisher later defines the placement's inner GPT div. **Architecture:** TS creates its fallback on the resolved inner div and records a -handoff claim. Narrow, idempotent GPT wrappers also gate a configured publisher -slot's first `display`/`refresh` while the server auction result is unavailable. At -`adInit()`, TS applies targeting to that same publisher slot and replays the held -native request once; it does not issue a second TS refresh. Late-definition handoff -and SPA ownership transfer remain unchanged. The head bootstrap and full TSJS bundle +handoff claim. Narrow, idempotent wrappers around GPT's `defineSlot`, `display`, and +`pubads().refresh` alias a matching late publisher definition to that slot and +suppress only the duplicate initial publisher request. A successful handoff transfers +SPA-destruction ownership to the publisher. The head bootstrap and full TSJS bundle share this runtime protocol through `window.tsjs`. **Primary files:** @@ -45,9 +43,9 @@ share this runtime protocol through `window.tsjs`. - Modify `crates/trusted-server-js/lib/src/core/types.ts` - Modify `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` -- [ ] Add `TsjsApi` state for both the div-ID-keyed late-handoff registry and an - initial publisher-request gate. The gate records held display/refresh IDs and - a released marker so it applies only once per page load. +- [ ] Add a `TsjsApi` property for a div-ID-keyed handoff registry. Each entry must + retain serializable lifecycle flags: TS-created, ownership-transferred, initial + request made, and one-shot publisher display/refresh suppression state. - [ ] Add only the minimal optional/internal type surface needed for idempotence markers on GPT functions and `pubads`. Do not weaken the public GPT types with `any`. @@ -83,14 +81,14 @@ npx vitest run test/integrations/gpt/ad_init.test.ts test/integrations/gpt/index returning it; - log, but do not create a second slot, if publisher arguments differ from the TS configuration. -- [ ] `display` wrapper: consume the one permitted post-handoff display; before the - first `adInit()`, also hold a configured publisher slot's native display. -- [ ] `refresh` wrapper: consume one permitted post-handoff disabled-load refresh; - before the first `adInit()`, hold configured publisher refreshes and forward - all unrelated slots explicitly, including a no-argument/global refresh. -- [ ] At initial `adInit()`, apply targeting then replay held native calls; never - refresh an existing publisher-owned slot that has already requested. -- [ ] Ensure wrapper installation precedes publisher setup and fallback creation. +- [ ] `display` wrapper: consume the one permitted publisher post-handoff display + call without invoking native `display`; pass every other call through unchanged. +- [ ] `refresh` wrapper: when initial load was disabled, consume the one permitted + post-handoff refresh for each claimed slot. If called with no slot list, expand + `getSlots()`, filter only the claimed slots, and forward the remaining slots + explicitly. Preserve all unrelated refreshes. +- [ ] Ensure wrapper installation precedes the fallback definition path and does not + change existing publisher-owned-slot behavior. **Focused checks:** @@ -138,9 +136,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts makes one request; the publisher's first refresh cannot make a second request. - [ ] Add a no-argument publisher refresh test containing an unrelated slot. Assert the claimed slot is suppressed once and the unrelated slot is refreshed. -- [ ] Add publisher-owned tests proving TS holds normal and disabled-load initial - requests, applies targeting, and replays exactly one native request. Also prove - an already-requested publisher slot is not refreshed again. +- [ ] Add an already publisher-owned test proving TS does not install a claim, applies + targeting, and refreshes that slot. - [ ] Add a no-publisher test proving TS still creates, displays, and requests its inner-div slot exactly once. - [ ] Add a SPA handoff test: after late publisher claim, the next `adInit()` does not @@ -156,8 +153,8 @@ npx vitest run test/integrations/gpt/ad_init.test.ts - Modify `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify `crates/trusted-server-core/src/integrations/gpt.rs` -- [ ] Port the same initial-request gate, actual-inner-div fallback, registry names, - lifecycle flags, and idempotence markers to the plain-JavaScript bootstrap. +- [ ] Port the same actual-inner-div fallback, registry names, lifecycle flags, and + idempotence markers to the plain-JavaScript bootstrap. - [ ] Use the existing bootstrap `window.tsjs` properties exactly so `index.ts` can adopt the initial claim after the bundle loads. - [ ] Ensure its internal definition/display/refresh calls use the same guards as the @@ -201,10 +198,9 @@ npx vitest run test/integrations/gpt/ad_init.test.ts format. - [ ] Review the diff specifically for bootstrap/bundle protocol drift and for any use of container IDs in GPT slot creation. -- [ ] In a controlled production-like browser capture with the hydration-safe - deferred `adInit()` path, verify one targeted initial request for each affected - visible placement and independently verify an unrelated placement remains - requestable. +- [ ] In a controlled production-like browser capture, verify one initial request for + each affected visible placement and independently verify an unrelated placement + remains requestable. - [ ] Update issue #944 with the ownership-handoff decision, test evidence, and browser-capture result. diff --git a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md index c94e25b2c..770718199 100644 --- a/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -8,12 +8,6 @@ slot on the outer `-container` element. The publisher subsequently defines and displays an inner-div slot. These are distinct GPT slots, so they make separate GAM requests for one visible placement. -A production deployment also exposed the inverse ordering: the hydration-safe -body bootstrap delays `adInit()` until after `window.load`, so publisher code can -already have defined **and requested** its inner-div slot. In that ordering, -reusing the slot and refreshing it applies targeting too late and creates a second -SRA request. - The affected paths are deliberately duplicated today: - `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` is the full bundle @@ -46,7 +40,7 @@ A fix must keep both implementations in sync. publisher-owned slot from a placement that the publisher will never define. - General interception of unrelated GPT slots. -## Decision: inner-div fallback, late-definition handoff, and an initial request gate +## Decision: one inner-div slot with late-definition handoff TS will define its fallback slot on the **actual inner div**, never on its outer `-container` element. It will record a narrowly scoped handoff claim keyed by that @@ -60,36 +54,31 @@ competing container slot and an invalid duplicate definition. ### Lifecycle -1. **Publisher-owned before bids are available** — a scoped head-installed gate - holds the configured placement's first publisher `display()` or `refresh()`. - At `adInit()`, TS finds the publisher slot, applies targeting, and replays that - held native call exactly once. It never adds a second TS refresh. -2. **Already-requested publisher-owned slot** — if a configured publisher request - was not observed by the gate, TS applies targeting for later lifecycle work but - does not re-request the already-served initial impression. -3. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, +1. **Already publisher-owned** — `getSlots()` finds a slot for the resolved inner + div. TS applies targeting, records it as publisher-owned, and refreshes it as it + does today. +2. **No slot yet** — TS defines a slot on the resolved inner div, applies targeting, enables services when needed, and displays it. When initial load is disabled, TS performs its existing one explicit refresh. TS records this slot as TS-owned and handoff-eligible. -4. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded +3. **Publisher defines later** — the scoped `defineSlot` wrapper sees the recorded inner-div claim, returns the existing slot, and transfers ownership: it removes the slot from TS's future `destroySlots()` set. The publisher's setup continues against that same slot. -5. **Publisher's first request call after a late handoff** — the wrapper suppresses the duplicate +4. **Publisher's first request call** — the wrapper suppresses the duplicate publisher `display()` call. With `disableInitialLoad()`, it instead suppresses only the publisher's first refresh for the transferred slot, because TS has already issued the required initial refresh. For a no-argument/global refresh, the wrapper must expand `getSlots()`, remove only the one-shot suppressed slots, and forward the remaining slots explicitly so unrelated slots still refresh. -6. **Later refreshes and SPA navigation** — after the one-shot suppression is +5. **Later refreshes and SPA navigation** — after the one-shot suppression is consumed, publisher refreshes are untouched. On navigation, TS clears its targeting from the shared slot and may reuse it for the next route; it must not destroy a slot after ownership has transferred. -The wrappers are not global deduplicators. The initial request gate only holds the -first `display`/`refresh` for a configured placement until initial TS targeting is -available; handoff suppression only handles IDs present in TS's handoff registry. -All unrelated GPT calls retain native behavior. +The wrapper is not a global deduplicator. It only handles IDs present in TS's +handoff registry and must preserve native `defineSlot`, `display`, and `refresh` +behavior for every other placement. ## Implementation shape @@ -100,10 +89,8 @@ can read after the bundle replaces the bootstrap implementation. It is keyed by resolved actual div ID and records at least: - whether TS created the slot and whether ownership has transferred; -- whether one post-handoff publisher `display()` or initial-load-disabled `refresh()` - remains to suppress; -- configured publisher displays and refreshes held before initial targeting, plus a - released marker so the gate applies only once per page load. +- whether one publisher `display()` or initial-load-disabled `refresh()` remains to + suppress. Do not rely only on module-local state: the bootstrap can define the initial slot before `index.ts` is loaded. Look up the live slot by element ID through @@ -122,12 +109,8 @@ In `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: - Replace the container fallback with `actualDivId`. - Add the typed handoff-registry state to `TsjsApi` in `crates/trusted-server-js/lib/src/core/types.ts`. -- Install idempotent `defineSlot`, `display`, and `pubads().refresh` wrappers from - the GPT command queue before publisher setup. The latter two also hold the first - configured publisher request until `adInit()` has applied initial targeting. -- Replay held initial publisher displays/refreshes after targeting rather than - refreshing an existing publisher-owned slot. Retain reused-slot refreshes only for - later SPA navigations. +- Install the idempotent `defineSlot`, `display`, and `pubads().refresh` handoff + wrappers from the GPT command queue before `adInit()` can create a fallback slot. - When a late publisher definition is aliased to the existing slot, remove it from `prevGptSlots` and mark it transferred before returning it. - Keep targeting cleanup keyed by the real inner div. Remove the old dual @@ -149,7 +132,7 @@ suite must exercise both implementations' observable contract. | Risk | Mitigation | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Publisher passes a different ad-unit path or sizes in its late `defineSlot` call | Return the existing claimed slot but log a diagnostic. Do not define a second slot. Treat the TS configuration and publisher configuration mismatch as an integration error to resolve separately. | -| Publisher invokes global `refresh()` before bids after `disableInitialLoad()` | Filter only configured held slots from the expanded list, forward unrelated slots immediately, then replay the held slots once after targeting. A no-argument refresh must not be silently dropped. | +| Publisher invokes global `refresh()` after `disableInitialLoad()` | Filter the one-shot claimed slot from the expanded slot list and refresh all remaining slots. A no-argument refresh must not be silently dropped. | | Publisher calls a legitimate refresh without an initial display | The one-shot suppression is consumed only immediately after a successful late handoff. Document and test the standard publisher sequence (`defineSlot` → `addService` → `display`, with `refresh` when initial load is disabled). Escalate unusual publisher lifecycle requirements rather than adding a time heuristic. | | Publisher-owned slot is destroyed on SPA navigation | Transfer ownership synchronously in the `defineSlot` wrapper and remove the slot from `prevGptSlots`. | | Bootstrap and bundle diverge | Give both paths the same black-box regression cases; retain a Rust source-contract assertion for bootstrap-specific sentinels. | @@ -163,9 +146,7 @@ suite must exercise both implementations' observable contract. initial-load-disabled modes. - The late publisher `display()` (and its first initial-load-disabled refresh) cannot create a second request, while unrelated slots retain their normal calls. -- A configured publisher slot whose first request occurs before the deferred - `adInit()` is held, receives TS targeting, and makes exactly one replayed native - request. An already-requested publisher slot is never re-requested by TS. +- Existing publisher slots are still reused and receive TS targeting. - A slot that no publisher claims is displayed and requested once by TS. - A transferred slot is absent from TS's SPA `destroySlots()` argument; targeting is still cleared and reapplied correctly on the next route. @@ -179,7 +160,6 @@ suite must exercise both implementations' observable contract. 2. Run the focused GPT test files, then the full TSJS Vitest suite and formatter. 3. Run the target-matched Rust test suite so the included bootstrap and its source assertions compile and pass. -4. In a controlled browser capture with deferred `adInit()`, verify that one - configured header and one configured fixed placement each produce one initial - slot request with TS targeting, while a distinct in-content placement remains - independently requestable. +4. In a controlled browser capture, verify that one configured header and one + configured fixed placement each produce one initial slot request, while a distinct + in-content placement remains independently requestable. From 001ad385c5c67b18b4de963bf1b233c57e793370 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:04:53 -0700 Subject: [PATCH 122/494] Decouple the prebid tsjs shim from the bundled Prebid.js The external bundle is now pure Prebid.js (core, consent and user ID modules, client-side bid adapters) and stamps a manifest on window.__tsjs_prebid_bundle. The shim ships as a server-served deferred tsjs module that installs the trustedServer adapter onto the window.pbjs global via public APIs only, so shim fixes deploy with the server instead of requiring an external bundle re-upload. --- .../src/integrations/prebid.rs | 6 +- .../src/integrations/registry.rs | 22 ++-- crates/trusted-server-core/src/publisher.rs | 6 +- crates/trusted-server-core/src/tsjs.rs | 11 +- crates/trusted-server-js/lib/build-all.mjs | 11 +- .../lib/build-prebid-external.mjs | 40 ++++++- .../lib/src/integrations/prebid/index.ts | 110 ++++++++++++------ .../test/integrations/prebid/index.test.ts | 98 +++++++++------- docs/guide/integrations/prebid.md | 12 +- 9 files changed, 208 insertions(+), 108 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a1b40281d..c15248fb0 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -921,7 +921,7 @@ pub fn register( .with_proxy(integration.clone()) .with_attribute_rewriter(integration.clone()) .with_head_injector(integration) - .without_js() + .with_deferred_js() .build(), )) } @@ -2937,8 +2937,8 @@ passphrase = "test-secret-key-32-bytes-minimum" "External prebid bundle route should be injected" ); assert!( - !processed.contains("tsjs-prebid.min.js"), - "Embedded deferred prebid bundle should not be injected" + processed.contains("tsjs-prebid.min.js"), + "Deferred tsjs prebid shim should be injected" ); } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 6f1b4dcfd..23e83d1de 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1949,7 +1949,7 @@ mod tests { } #[test] - fn js_module_ids_exclude_prebid_and_include_core_js_only_modules() { + fn js_module_ids_defer_prebid_and_include_core_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1975,8 +1975,8 @@ mod tests { let deferred = registry.js_module_ids_deferred(); assert!( - !all.contains(&"prebid"), - "should not include prebid in embedded TSJS module IDs" + all.contains(&"prebid"), + "should include the prebid shim in embedded TSJS module IDs" ); assert!( immediate.contains(&"creative"), @@ -1991,8 +1991,8 @@ mod tests { "should not include prebid in immediate IDs" ); assert!( - !deferred.contains(&"prebid"), - "should not include prebid in deferred IDs" + deferred.contains(&"prebid"), + "should serve the prebid shim as a deferred module" ); } @@ -2077,7 +2077,7 @@ mod tests { } #[test] - fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() { + fn js_module_ids_defer_prebid_shim_when_external_bundle_is_configured() { let mut settings = crate::test_support::tests::create_test_settings(); settings .integrations @@ -2094,16 +2094,16 @@ mod tests { let registry = IntegrationRegistry::new(&settings).expect("should create registry"); assert!( - !registry.js_module_ids().contains(&"prebid"), - "external bundle mode should not include prebid in embedded TSJS modules" + registry.js_module_ids().contains(&"prebid"), + "external bundle mode should include the prebid shim in embedded TSJS modules" ); assert!( !registry.js_module_ids_immediate().contains(&"prebid"), - "external bundle mode should not include prebid in immediate TSJS modules" + "the prebid shim should not load in the immediate TSJS bundle" ); assert!( - !registry.js_module_ids_deferred().contains(&"prebid"), - "external bundle mode should not include prebid in deferred TSJS modules" + registry.js_module_ids_deferred().contains(&"prebid"), + "the prebid shim should load as a deferred TSJS module" ); assert!( registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d6e9d2ee..32c4b37ab 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3796,7 +3796,7 @@ mod tests { } #[test] - fn tsjs_dynamic_does_not_serve_embedded_prebid() { + fn tsjs_dynamic_serves_prebid_shim_when_enabled() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -3808,8 +3808,8 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( response.status(), - StatusCode::NOT_FOUND, - "should not serve embedded prebid module" + StatusCode::OK, + "should serve the deferred prebid shim module when prebid is enabled" ); } diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 45aee02eb..a7b4cc2ef 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -192,12 +192,13 @@ mod tests { } #[test] - fn tsjs_deferred_script_src_uses_empty_hash_for_external_or_unknown_module() { - assert_eq!( - tsjs_deferred_script_src("prebid"), - "/static/tsjs=tsjs-prebid.min.js?v=", - "prebid now ships as an external bundle and has no local hash" + fn tsjs_deferred_script_src_hashes_prebid_shim_and_empties_unknown_module() { + let prebid_src = tsjs_deferred_script_src("prebid"); + assert!( + prebid_src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), + "prebid shim should be served from the deferred tsjs route" ); + assert_sha256_hex_hash(hash_query_value(&prebid_src)); assert_eq!( tsjs_deferred_script_src("unknown-module"), "/static/tsjs=tsjs-unknown-module.min.js?v=", diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index df261bd4f..2bfee01b1 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -8,9 +8,10 @@ * tsjs-core.js — core API (always included) * tsjs-.js — one per discovered integration * - * Prebid is intentionally excluded from this embedded build. Use - * build-prebid-external.mjs to generate publisher-specific Prebid bundles - * outside the Cargo build. + * The prebid integration builds here as the tsjs shim only — Prebid.js itself + * is never bundled into tsjs. Use build-prebid-external.mjs to generate the + * pure Prebid.js external bundle (core + adapters + user ID modules) that the + * shim requires at runtime via integrations.prebid.external_bundle_url. */ import fs from 'node:fs'; @@ -34,9 +35,7 @@ const integrationModules = fs.existsSync(integrationsDir) .filter((name) => { const fullPath = path.join(integrationsDir, name); return ( - name !== 'prebid' && - fs.statSync(fullPath).isDirectory() && - fs.existsSync(path.join(fullPath, 'index.ts')) + fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) ); }) .sort() diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 4e89723ed..8c343065c 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -182,9 +182,39 @@ function createTemporaryModulePaths() { temporaryDir, adaptersFile: path.join(temporaryDir, '_adapters.generated.ts'), userIdsFile: path.join(temporaryDir, '_user_ids.generated.ts'), + entryFile: path.join(temporaryDir, '_external_entry.generated.ts'), }; } +function generateExternalEntry(entryFile, adapters) { + const content = [ + '// Auto-generated by build-prebid-external.mjs.', + '//', + '// Pure Prebid.js external bundle: core, consent modules, user ID modules,', + '// and client-side bid adapters. The Trusted Server prebid shim', + '// (tsjs-prebid, served by the server) installs the trustedServer adapter', + '// onto the `window.pbjs` global this bundle populates and drives queue', + '// processing — this bundle intentionally does NOT call processQueue().', + "import 'prebid.js';", + "import 'prebid.js/modules/consentManagementTcf.js';", + "import 'prebid.js/modules/consentManagementGpp.js';", + "import 'prebid.js/modules/consentManagementUsp.js';", + "import 'prebid.js/modules/userId.js';", + "import './_adapters.generated';", + "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", + '', + '// Manifest consumed by the tsjs prebid shim to validate that every', + '// configured client_side_bidder has its adapter compiled in.', + '(window as unknown as Record).__tsjs_prebid_bundle = Object.freeze({', + ` adapters: ${JSON.stringify(adapters)},`, + ' userIdModules: INCLUDED_PREBID_USER_ID_MODULES,', + '});', + '', + ].join('\n'); + + fs.writeFileSync(entryFile, content); +} + export function deriveBundleMetadata(bundleBytes) { const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`; @@ -224,6 +254,13 @@ async function buildExternalBundle(outDir, generatedModules) { 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), }, + { + find: 'prebid.js/src/adRendering.js', + replacement: path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adRendering.js' + ), + }, ], }, build: { @@ -233,7 +270,7 @@ async function buildExternalBundle(outDir, generatedModules) { sourcemap: false, minify: 'esbuild', rollupOptions: { - input: path.join(prebidDir, 'index.ts'), + input: generatedModules.entryFile, output: { format: 'iife', dir: outDir, @@ -270,6 +307,7 @@ export async function main(argv = process.argv.slice(2)) { try { const adapters = generateAdapterImports(args.adapters, generatedModules.adaptersFile); const userIdModules = generateUserIdImports(args.userIdModules, generatedModules.userIdsFile); + generateExternalEntry(generatedModules.entryFile, adapters); const bundle = await buildExternalBundle(args.outDir, generatedModules); const manifest = { prebidVersion: prebidPackageVersion(), diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 342e4038d..825dfa628 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -11,29 +11,55 @@ // The shim on requestBids injects "trustedServer" into every ad unit so all // bids flow through the orchestrator. -import pbjs from 'prebid.js'; -import adapterManager from 'prebid.js/src/adapterManager.js'; -import 'prebid.js/modules/consentManagementTcf.js'; -import 'prebid.js/modules/consentManagementGpp.js'; -import 'prebid.js/modules/consentManagementUsp.js'; -import 'prebid.js/modules/userId.js'; - -// Client-side bid adapters — self-register with prebid.js on import. -// The external bundle generator aliases these placeholder modules to temporary -// modules built from its --adapters and --user-id-modules options. When a bidder -// is listed in `client_side_bidders` in trusted-server.toml, the requestBids -// shim leaves its bids untouched and the corresponding adapter handles them -// natively in the browser. -import './_adapters.generated'; - import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; +import type _pbjsDefault from 'prebid.js'; -import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +/** + * Prebid.js public API surface (type-only; erased at build time). + * + * `getUserIdsAsEids` is added by the userId module at runtime, which the base + * package typing does not model. + */ +type PbjsGlobal = typeof _pbjsDefault & { + getUserIdsAsEids?: () => unknown[]; +}; + +// Prebid.js itself is NOT bundled into this module. It is served as the +// external bundle configured via `integrations.prebid.external_bundle_url` +// (required whenever the prebid integration is enabled) and owns the +// `window.pbjs` global. The Rust head injector emits a stub +// (`window.pbjs = window.pbjs || {que:[],cmd:[]}`) before any script runs and +// Prebid.js installs its API onto that same object, so capturing the reference +// at module scope is safe regardless of evaluation order. +const pbjs: PbjsGlobal = ( + typeof window !== 'undefined' + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((window as any).pbjs ??= { que: [], cmd: [] }) + : { que: [], cmd: [] } +) as PbjsGlobal; + +/** + * Manifest stamped on `window.__tsjs_prebid_bundle` by the external Prebid.js + * bundle (see build-prebid-external.mjs): which client-side bid adapters and + * user ID modules were compiled into it. + */ +interface ExternalPrebidBundleManifest { + adapters?: string[]; + userIdModules?: string[]; +} + +function getExternalBundleManifest(): ExternalPrebidBundleManifest | undefined { + if (typeof window === 'undefined') { + return undefined; + } + return (window as { __tsjs_prebid_bundle?: ExternalPrebidBundleManifest }).__tsjs_prebid_bundle; +} + const ADAPTER_CODE = 'trustedServer'; // OpenRTB permits vendor-specific agent types; PAIR uses 571187. // Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. @@ -139,10 +165,11 @@ function readConfiguredUserIdNames(): string[] { } function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { + const includedUserIdModules = getExternalBundleManifest()?.userIdModules ?? []; const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort(); const coveredConfigNames = new Set( PREBID_USER_ID_MODULE_REGISTRY.filter((entry) => - INCLUDED_PREBID_USER_ID_MODULES.includes(entry.moduleName) + includedUserIdModules.includes(entry.moduleName) ).flatMap((entry) => entry.configNames) ); const missingConfiguredUserIdNames = configuredUserIdNames.filter( @@ -150,7 +177,7 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { ); const diagnostics: PrebidUserIdDiagnostics = { - includedModules: [...INCLUDED_PREBID_USER_ID_MODULES], + includedModules: [...includedUserIdModules], configuredUserIdNames, missingConfiguredUserIdNames, }; @@ -502,6 +529,18 @@ function collectAuctionEids(): AuctionEid[] | undefined { * 2. `config` argument — explicit overrides from the publisher's JS */ export function installPrebidNpm(config?: Partial): typeof pbjs { + // The prebid integration requires the external Prebid.js bundle + // (integrations.prebid.external_bundle_url). When it failed to load (network + // error, SRI mismatch) window.pbjs is still the head-injected stub with no + // API — installing the adapter is impossible, so bail out loudly. + if (typeof (pbjs as { registerBidAdapter?: unknown }).registerBidAdapter !== 'function') { + log.error( + '[tsjs-prebid] window.pbjs has no Prebid.js API — the external Prebid bundle ' + + 'failed to load. Prebid integration disabled.' + ); + return pbjs; + } + const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { endpoint: config?.endpoint, @@ -661,7 +700,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); if (typeof originalBidsBack === 'function') { - originalBidsBack.apply(this, args); + (originalBidsBack as (...handlerArgs: unknown[]) => void).apply(this, args); } }; @@ -682,24 +721,27 @@ export function installPrebidNpm(config?: Partial): typeof pbjs pbjs.processQueue(); recordUserIdModuleDiagnostics(); - // Validate that every client-side bidder has its adapter registered. - // Adapters self-register on import, so a missing adapter means the bidder - // was listed in client_side_bidders but not included in the generated - // external Prebid bundle. Without the adapter the bidder is silently dropped - // from both server-side and client-side auctions. - for (const bidder of clientSideBidders) { - try { - if (!adapterManager.getBidAdapter(bidder)) { + // Validate that every client-side bidder has its adapter compiled into the + // external Prebid.js bundle. The bundle stamps its adapter list on + // window.__tsjs_prebid_bundle; a missing adapter means the bidder was listed + // in client_side_bidders but not included in the generated bundle, so it is + // silently dropped from both server-side and client-side auctions. + const bundledAdapters = getExternalBundleManifest()?.adapters; + if (bundledAdapters === undefined) { + if (clientSideBidders.size > 0) { + log.warn( + '[tsjs-prebid] external Prebid bundle did not stamp an adapter manifest; ' + + 'cannot verify client_side_bidders adapters' + ); + } + } else { + for (const bidder of clientSideBidders) { + if (!bundledAdapters.includes(bidder)) { log.error( - `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to build-prebid-external.mjs --adapters.` + `[tsjs-prebid] client-side bidder "${bidder}" has no adapter in the external ` + + `Prebid bundle. Add it to build-prebid-external.mjs --adapters.` ); } - } catch { - log.error( - `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to build-prebid-external.mjs --adapters.` - ); } } diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 726f40b49..8827ddfea 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1,6 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -// Define mocks using vi.hoisted so they're available inside vi.mock factories +/** + * Default external-bundle manifest for tests. Mirrors what the real external + * Prebid.js bundle stamps on `window.__tsjs_prebid_bundle` (see + * build-prebid-external.mjs). Individual tests override and restore it. + */ +const DEFAULT_BUNDLE_MANIFEST = { + adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + userIdModules: ['sharedIdSystem'], +}; + +// Define mocks using vi.hoisted so they exist before the module under test is +// imported. The shim reads Prebid.js from the `window.pbjs` global (owned by +// the external bundle in production), so tests install the mock there instead +// of mocking module imports. const { mockSetConfig, mockProcessQueue, @@ -9,14 +22,11 @@ const { mockGetUserIdsAsEids, mockGetConfig, mockPbjs, - mockGetBidAdapter, - mockAdapterManager, } = vi.hoisted(() => { const mockSetConfig = vi.fn(); const mockProcessQueue = vi.fn(); const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); - const mockGetBidAdapter = vi.fn(); const mockGetUserIdsAsEids = vi.fn( () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); @@ -29,10 +39,20 @@ const { getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, adUnits: [] as any[], + que: [] as Array<() => void>, + cmd: [] as Array<() => void>, }; - const mockAdapterManager = { - getBidAdapter: mockGetBidAdapter, + + // Install the mock global BEFORE the shim module evaluates — the shim + // captures `window.pbjs` at module scope. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis.window as any; + w.pbjs = mockPbjs; + w.__tsjs_prebid_bundle = { + adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], + userIdModules: ['sharedIdSystem'], }; + return { mockSetConfig, mockProcessQueue, @@ -41,28 +61,9 @@ const { mockGetUserIdsAsEids, mockGetConfig, mockPbjs, - mockGetBidAdapter, - mockAdapterManager, }; }); -// Mock prebid.js before importing the module under test. -// The real prebid.js cannot run in jsdom, so we provide a minimal stub. -vi.mock('prebid.js', () => ({ default: mockPbjs })); -vi.mock('prebid.js/src/adapterManager.js', () => ({ default: mockAdapterManager })); - -// Side-effect imports are no-ops in tests -vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); -vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); -vi.mock('prebid.js/modules/userId.js', () => ({})); - -// Mock the build-generated imports in tests. -vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); -vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({ - INCLUDED_PREBID_USER_ID_MODULES: ['sharedIdSystem'], -})); - import { collectBidders, getInjectedConfig, @@ -1410,8 +1411,8 @@ describe('prebid/client-side bidders', () => { mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); - // By default, pretend all adapters are registered - mockGetBidAdapter.mockReturnValue({}); + // By default the manifest declares all adapters compiled in. + (window as any).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; delete (window as any).__tsjs_prebid; }); @@ -1558,21 +1559,18 @@ describe('prebid/client-side bidders', () => { expect(tsBid.params.bidderParams).toEqual({}); }); - it('logs error when a client-side bidder has no adapter loaded', () => { - // rubicon is registered, but openx is not - mockGetBidAdapter.mockImplementation((bidder: string) => - bidder === 'rubicon' ? {} : undefined - ); + it('logs error when a client-side bidder has no adapter in the external bundle', () => { + // rubicon is compiled into the external bundle, but openx is not + (window as any).__tsjs_prebid_bundle = { + ...DEFAULT_BUNDLE_MANIFEST, + adapters: ['rubicon'], + }; (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); installPrebidNpm(); - // Should have been called to check both bidders - expect(mockGetBidAdapter).toHaveBeenCalledWith('rubicon'); - expect(mockGetBidAdapter).toHaveBeenCalledWith('openx'); - // Should log an error for the missing adapter. // log.error() uses styled console output: console.error('%c[tsjs]%c ...:', style, reset, ...args) // so the actual message is the 4th argument. @@ -1580,22 +1578,40 @@ describe('prebid/client-side bidders', () => { const hasOpenxError = errorCalls.some((args) => args.some( (a) => - typeof a === 'string' && a.includes('client-side bidder "openx" has no adapter loaded') + typeof a === 'string' && + a.includes('client-side bidder "openx" has no adapter in the external Prebid bundle') ) ); expect(hasOpenxError).toBe(true); - // Should NOT log an error for the registered adapter + // Should NOT log an error for the compiled-in adapter const hasRubiconError = errorCalls.some((args) => args.some((a) => typeof a === 'string' && a.includes('client-side bidder "rubicon"')) ); expect(hasRubiconError).toBe(false); errorSpy.mockRestore(); + (window as any).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + }); + + it('warns when the external bundle stamped no adapter manifest', () => { + delete (window as any).__tsjs_prebid_bundle; + (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + installPrebidNpm(); + + const hasManifestWarn = warnSpy.mock.calls.some((args) => + args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) + ); + expect(hasManifestWarn).toBe(true); + + warnSpy.mockRestore(); + (window as any).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); it('does not log errors when all client-side bidders have adapters', () => { - mockGetBidAdapter.mockReturnValue({}); (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -1603,7 +1619,9 @@ describe('prebid/client-side bidders', () => { installPrebidNpm(); const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('has no adapter loaded')) + args.some( + (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') + ) ); expect(hasAdapterError).toBe(false); diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 4bc73c0ce..e496fdd3c 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -374,11 +374,13 @@ available modules and default preset are checked in at `--user-id-modules` to `build-prebid-external.mjs` when a publisher needs a specific subset; omit it to use the default preset. -This is deliberate: Trusted Server injects a generated Prebid.js bundle so we -can install the `trustedServer` adapter and route auctions through `/auction`, -but publishers often need different User ID submodules. Moving that selection to -the external bundle keeps publisher-specific Prebid choices out of the Trusted -Server WASM artifact while preserving a manifest and bundle hash for auditing. +This is deliberate: the external bundle is pure Prebid.js (core, consent and +User ID modules, and client-side bid adapters) while the server-served TSJS +prebid shim installs the `trustedServer` adapter onto `window.pbjs` and routes +auctions through `/auction` — but publishers often need different User ID +submodules. Moving that selection to the external bundle keeps +publisher-specific Prebid choices out of the Trusted Server WASM artifact while +preserving a manifest and bundle hash for auditing. The current preset includes common ID modules such as Yahoo ConnectID, Criteo, LiveIntent, SharedID, UID2, ID5, LiveRamp IdentityLink, PubProvidedID, and From f63f31aa74b4f7da32ac59f344f23a3008ec95b3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:49:23 -0700 Subject: [PATCH 123/494] Order the prebid.js type import before relative imports --- crates/trusted-server-js/lib/src/integrations/prebid/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 825dfa628..6e97a1aa8 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -11,11 +11,12 @@ // The shim on requestBids injects "trustedServer" into every ad unit so all // bids flow through the orchestrator. +import type _pbjsDefault from 'prebid.js'; + import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; -import type _pbjsDefault from 'prebid.js'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; From 2ddd19311397e6f5c49e6f2f3a52598028741e61 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:28:46 -0700 Subject: [PATCH 124/494] Lint test files and replace explicit any casts with typed helpers Widen the lint script to cover test/**, and make every test file pass it with real types: typed window views, TestBid/TestAdUnit shapes, adapter spec and requestBids parameter types, typeof-fetch casts for fetch mocks, and signature-free spies instead of unused typed parameters. --- crates/trusted-server-js/lib/package.json | 4 +- .../lib/test/core/auction.test.ts | 11 +- .../lib/test/core/config.test.ts | 2 +- .../lib/test/core/index.test.ts | 20 +- .../lib/test/core/registry.test.ts | 2 +- .../lib/test/core/request.test.ts | 74 +++-- .../test/integrations/creative/click.test.ts | 2 +- .../integrations/creative/proxy_sign.test.ts | 2 +- .../datadome/script_guard.test.ts | 1 + .../test/integrations/didomi/index.test.ts | 6 +- .../lib/test/integrations/gpt/index.test.ts | 30 +- .../integrations/lockr/script_guard.test.ts | 1 + .../test/integrations/prebid/index.test.ts | 280 +++++++++++------- .../lib/test/shared/beacon_guard.test.ts | 7 +- 14 files changed, 279 insertions(+), 163 deletions(-) diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 2ffed57e7..47f4e29cf 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -10,8 +10,8 @@ "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint \"src/**/*.{ts,tsx}\"", - "lint:fix": "eslint --fix \"src/**/*.{ts,tsx}\"", + "lint": "eslint \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\"", + "lint:fix": "eslint --fix \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\"", "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"", "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\"" }, diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 31e020eff..50f078d0b 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/core/auction'; describe('auction/buildAdRequest', () => { @@ -247,7 +248,7 @@ describe('auction/sendAuction', () => { ], }), }; - globalThis.fetch = vi.fn().mockResolvedValue(mockResponse) as any; + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse) as unknown as typeof fetch; const request = { adUnits: [ @@ -274,7 +275,9 @@ describe('auction/sendAuction', () => { }); it('returns empty array on network error', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error')) as any; + globalThis.fetch = vi + .fn() + .mockRejectedValue(new Error('network error')) as unknown as typeof fetch; const bids = await sendAuction('/auction', { adUnits: [] }); expect(bids).toEqual([]); @@ -286,7 +289,7 @@ describe('auction/sendAuction', () => { status: 200, headers: { get: () => 'text/html' }, json: async () => ({}), - }) as any; + }) as unknown as typeof fetch; const bids = await sendAuction('/auction', { adUnits: [] }); expect(bids).toEqual([]); @@ -298,7 +301,7 @@ describe('auction/sendAuction', () => { status: 500, headers: { get: () => 'application/json' }, json: async () => ({}), - }) as any; + }) as unknown as typeof fetch; const bids = await sendAuction('/auction', { adUnits: [] }); expect(bids).toEqual([]); diff --git a/crates/trusted-server-js/lib/test/core/config.test.ts b/crates/trusted-server-js/lib/test/core/config.test.ts index 2b15d1929..f2d849320 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -16,7 +16,7 @@ describe('config', () => { setConfig({ debug: true }); expect(log.getLevel()).toBe('debug'); - setConfig({ logLevel: 'info' as any }); + setConfig({ logLevel: 'info' } as Parameters[0]); expect(log.getLevel()).toBe('info'); }); }); diff --git a/crates/trusted-server-js/lib/test/core/index.test.ts b/crates/trusted-server-js/lib/test/core/index.test.ts index dc77439b1..7b887474a 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -1,18 +1,24 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -declare global { - interface Window { - tsjs?: any; - } +interface TsjsTestWindow { + tsjs?: { + que?: Array<() => void>; + version?: string; + setConfig?: unknown; + getConfig?: unknown; + log?: unknown; + } & Record; } +const testWindow = window as unknown as TsjsTestWindow; + const ORIGINAL_FETCH = global.fetch; describe('core/index', () => { beforeEach(async () => { await vi.resetModules(); document.body.innerHTML = ''; - delete (window as any).tsjs; + delete testWindow.tsjs; }); afterEach(() => { @@ -41,7 +47,7 @@ describe('core/index', () => { }); it('preserves edge-injected adSlots and bids set before the bundle loads', async () => { - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [{ id: 'pre-injected' }], bids: { 'pre-injected': { hb_pb: '1.00' } }, }; @@ -56,7 +62,7 @@ describe('core/index', () => { const callback = vi.fn(function () { expect(this).toBe(window.tsjs); }); - (window as any).tsjs = { que: [callback] }; + testWindow.tsjs = { que: [callback] }; await import('../../src/core/index'); diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index 726f67797..7190a085b 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -17,7 +17,7 @@ describe('registry', () => { ], }, }, - } as any; + } as unknown as Parameters[0]; addAdUnits(unit); const all = getAllUnits(); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 2c56361dc..efc18948f 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +/** Test view of the global scope with a mockable `fetch`. */ +const testGlobal = globalThis as unknown as { fetch: ReturnType }; + +type AddAdUnitsArg = Parameters[0]; + async function flushRequestAds(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -21,7 +26,7 @@ describe('request.requestAds', () => { it('sends fetch and renders creatives via iframe from response', async () => { // mock fetch - returns creative HTML inline in adm field const creativeHtml = '
Test Creative
'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -41,12 +46,15 @@ describe('request.requestAds', () => { const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => undefined); document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); - expect((globalThis as any).fetch).toHaveBeenCalled(); + expect(testGlobal.fetch).toHaveBeenCalled(); // Verify iframe was created with creative HTML in srcdoc const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; @@ -67,7 +75,7 @@ describe('request.requestAds', () => { }); it('does not render on non-JSON response', async () => { - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'text/plain' }, @@ -78,35 +86,41 @@ describe('request.requestAds', () => { const { requestAds } = await import('../../src/core/request'); document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); - expect((globalThis as any).fetch).toHaveBeenCalled(); + expect(testGlobal.fetch).toHaveBeenCalled(); expect(document.querySelector('iframe')).toBeNull(); }); it('ignores fetch rejection gracefully', async () => { - (globalThis as any).fetch = vi.fn().mockRejectedValue(new Error('network-error')); + testGlobal.fetch = vi.fn().mockRejectedValue(new Error('network-error')); const { addAdUnits } = await import('../../src/core/registry'); const { requestAds } = await import('../../src/core/request'); document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); - expect((globalThis as any).fetch).toHaveBeenCalled(); + expect(testGlobal.fetch).toHaveBeenCalled(); expect(document.querySelector('iframe')).toBeNull(); }); it('inserts an iframe with creative HTML from unified auction', async () => { // mock fetch for unified auction endpoint - returns inline HTML const creativeHtml = 'Ad'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -129,7 +143,10 @@ describe('request.requestAds', () => { document.body.appendChild(div); // Add an ad unit and request - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); @@ -144,7 +161,7 @@ describe('request.requestAds', () => { it('renders creatives with safe URI markup', async () => { const creativeHtml = 'Contactad'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -162,7 +179,10 @@ describe('request.requestAds', () => { const { requestAds } = await import('../../src/core/request'); document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); @@ -174,7 +194,7 @@ describe('request.requestAds', () => { }); it('rejects malformed non-string creative HTML without blanking the slot', async () => { - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -194,7 +214,10 @@ describe('request.requestAds', () => { const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); document.body.innerHTML = '
existing
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); @@ -221,7 +244,7 @@ describe('request.requestAds', () => { // Regression: multi-bid scenario where a rejected bid must not erase an earlier // successful render into the same slot. const goodCreative = '
Safe Ad
'; - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -244,7 +267,10 @@ describe('request.requestAds', () => { const { requestAds } = await import('../../src/core/request'); document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); @@ -256,7 +282,7 @@ describe('request.requestAds', () => { }); it('rejects creatives that sanitize to empty markup', async () => { - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -276,7 +302,10 @@ describe('request.requestAds', () => { const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); document.body.innerHTML = '
'; - addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'slot1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); @@ -298,7 +327,7 @@ describe('request.requestAds', () => { it('skips iframe insertion when slot is missing', async () => { // mock fetch for unified auction endpoint - returns inline HTML - (globalThis as any).fetch = vi.fn().mockResolvedValue({ + testGlobal.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => 'application/json' }, @@ -314,7 +343,10 @@ describe('request.requestAds', () => { const { addAdUnits } = await import('../../src/core/registry'); const { requestAds } = await import('../../src/core/request'); - addAdUnits({ code: 'missing-slot', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + addAdUnits({ + code: 'missing-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + } as unknown as AddAdUnitsArg); requestAds(); await flushRequestAds(); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index 05dcd0e02..7cf31afa3 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -17,7 +17,7 @@ describe('creative/click.ts', () => { it('repairs anchors via proxy rebuild fallback when fetch is unavailable', async () => { vi.useFakeTimers(); - global.fetch = undefined as any; + global.fetch = undefined as unknown as typeof fetch; const anchor = document.createElement('a'); anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts index 41c86a873..7f31dbed9 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts @@ -47,7 +47,7 @@ describe('creative/proxy_sign.ts', () => { }); it('returns null when fetch is unavailable', async () => { - global.fetch = undefined as any; + global.fetch = undefined as unknown as typeof fetch; const result = await signProxyUrl('https://cdn.example/asset.js'); expect(result).toBeNull(); }); diff --git a/crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts index 795b442a6..70fe191fd 100644 --- a/crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + import { installDataDomeGuard, isGuardInstalled, diff --git a/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts b/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts index 487ff471f..bc347968c 100644 --- a/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts @@ -5,7 +5,7 @@ import { installDidomiSdkProxy } from '../../../src/integrations/didomi'; const ORIGINAL_WINDOW = global.window; type TestDidomiWindow = Window & { - didomiConfig?: any; + didomiConfig?: Record; __tsjs_didomi?: { proxyPath?: string }; }; @@ -20,11 +20,11 @@ describe('integrations/didomi', () => { beforeEach(() => { testWindow = createWindow('https://example.com/page'); - Object.assign(globalThis as any, { window: testWindow }); + Object.assign(globalThis as unknown as { window: unknown }, { window: testWindow }); }); afterEach(() => { - Object.assign(globalThis as any, { window: ORIGINAL_WINDOW }); + Object.assign(globalThis as unknown as { window: unknown }, { window: ORIGINAL_WINDOW }); }); it('initializes didomiConfig and forces sdkPath through trusted server proxy', () => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index 406c6d1f5..9a3c774e1 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +/** Window properties these tests read and write on the jsdom global. */ +interface GptTestWindow { + googletag?: unknown; + tsjs?: Record & { adInit?: () => void }; +} + +const gptTestWindow = window as unknown as GptTestWindow; + // We import installGptShim dynamically so each test can control whether the // GPT enable flag is present before module evaluation. @@ -219,14 +227,14 @@ describe('GPT – installSlimPrebidLoader', () => { describe('GPT – installTsAdInit', () => { beforeEach(() => { document.body.innerHTML = ''; - delete (window as any).tsjs; - delete (window as any).googletag; + delete gptTestWindow.tsjs; + delete gptTestWindow.googletag; }); afterEach(() => { document.body.innerHTML = ''; - delete (window as any).tsjs; - delete (window as any).googletag; + delete gptTestWindow.tsjs; + delete gptTestWindow.googletag; }); it('clears stale TS-managed targeting before applying a new route to a reused GPT slot', async () => { @@ -240,7 +248,13 @@ describe('GPT – installTsAdInit', () => { ['ts_initial', ['1']], ['pos', ['old-pos']], ]); - const gptSlot: any = { + interface GptTestSlot { + getSlotElementId: () => string; + getTargeting: (key: string) => string[]; + setTargeting: (key: string, value: string | string[]) => GptTestSlot; + clearTargeting: (key?: string) => GptTestSlot; + } + const gptSlot: GptTestSlot = { getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), setTargeting: vi.fn((key: string, value: string | string[]) => { @@ -269,14 +283,14 @@ describe('GPT – installTsAdInit', () => { }; document.body.innerHTML = '
'; - (window as any).googletag = { + gptTestWindow.googletag = { cmd, pubads: () => pubads, defineSlot: vi.fn(), destroySlots: vi.fn(), enableServices: vi.fn(), }; - (window as any).tsjs = { + gptTestWindow.tsjs = { prevSlotTargetingKeys: { 'div-ad-homepage-header': ['pos'], }, @@ -293,7 +307,7 @@ describe('GPT – installTsAdInit', () => { }; installTsAdInit(); - (window as any).tsjs.adInit(); + gptTestWindow.tsjs?.adInit?.(); expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder'); diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts index b9251b1e1..2f53c06b2 100644 --- a/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + import { installLockrGuard, isGuardInstalled, diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 8827ddfea..665fddd42 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -10,6 +10,59 @@ const DEFAULT_BUNDLE_MANIFEST = { userIdModules: ['sharedIdSystem'], }; +/** Loose bid shape used by the requestBids shim tests. */ +interface TestBid { + bidder: string; + params?: Record; +} + +/** Loose ad unit shape used by the requestBids shim tests. */ +interface TestAdUnit { + code?: string; + bids?: TestBid[]; +} + +/** Window properties the prebid shim reads and writes in these tests. */ +interface PrebidTestWindow { + pbjs?: unknown; + tsjs?: unknown; + googletag?: unknown; + __tsjs_prebid?: Record; + __tsjs_prebid_bundle?: { adapters?: string[]; userIdModules?: string[] }; + __tsjs_prebid_diagnostics?: { + userIdModules?: { + includedModules: string[]; + configuredUserIdNames: string[]; + missingConfiguredUserIdNames: string[]; + }; + }; +} + +const testWindow = window as unknown as PrebidTestWindow; + +/** Argument type accepted by the shimmed `pbjs.requestBids`. */ +type RequestBidsArg = Parameters['requestBids']>[0]; + +/** The bid adapter spec object registered via `pbjs.registerBidAdapter`. */ +interface TestAdapterSpec { + code: string; + supportedMediaTypes: string[]; + isBidRequestValid: (bid: Record) => boolean; + buildRequests: ( + bidRequests: Array>, + bidderRequest?: Record + ) => { + method: string; + url: string; + data: Record; + options: Record; + }; + interpretResponse: ( + response: Record, + request?: Record + ) => Array>; +} + // Define mocks using vi.hoisted so they exist before the module under test is // imported. The shim reads Prebid.js from the `window.pbjs` global (owned by // the external bundle in production), so tests install the mock there instead @@ -38,15 +91,18 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, - adUnits: [] as any[], + adUnits: [] as TestAdUnit[], + setTargetingForGPTAsync: undefined as ((adUnitCodes?: string[]) => void) | undefined, que: [] as Array<() => void>, cmd: [] as Array<() => void>, }; // Install the mock global BEFORE the shim module evaluates — the shim // captures `window.pbjs` at module scope. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const w = globalThis.window as any; + const w = globalThis.window as unknown as { + pbjs?: unknown; + __tsjs_prebid_bundle?: unknown; + }; w.pbjs = mockPbjs; w.__tsjs_prebid_bundle = { adapters: ['rubicon', 'openx', 'exampleBrowser', 'appnexus'], @@ -103,7 +159,7 @@ describe('prebid/collectBidders', () => { describe('prebid/getInjectedConfig', () => { afterEach(() => { - delete (window as any).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); it('returns undefined when window.__tsjs_prebid is not set', () => { @@ -111,7 +167,7 @@ describe('prebid/getInjectedConfig', () => { }); it('returns the injected config when present', () => { - (window as any).__tsjs_prebid = { accountId: 'server-42', timeout: 2000 }; + testWindow.__tsjs_prebid = { accountId: 'server-42', timeout: 2000 }; expect(getInjectedConfig()).toEqual({ accountId: 'server-42', timeout: 2000 }); }); }); @@ -217,8 +273,8 @@ describe('prebid/installPrebidNpm', () => { mockGetUserIdsAsEids.mockReturnValue([]); mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete (window as any).__tsjs_prebid; - delete (window as any).__tsjs_prebid_diagnostics; + delete testWindow.__tsjs_prebid; + delete testWindow.__tsjs_prebid_diagnostics; }); afterEach(() => { @@ -268,7 +324,7 @@ describe('prebid/installPrebidNpm', () => { it('reports the User ID modules selected by the generated bundle', () => { installPrebidNpm(); - expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: [], missingConfiguredUserIdNames: [], @@ -285,7 +341,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.requestBids({ adUnits: [] }); mockPbjs.requestBids({ adUnits: [] }); - expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: ['pairId', 'sharedId'], missingConfiguredUserIdNames: ['pairId'], @@ -301,9 +357,9 @@ describe('prebid/installPrebidNpm', () => { }); describe('adapter spec', () => { - function getAdapterSpec(): any { + function getAdapterSpec(): TestAdapterSpec { installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0][2]; + return mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; } it('isBidRequestValid always returns true', () => { @@ -581,18 +637,18 @@ describe('prebid/installPrebidNpm', () => { { bids: [{ bidder: 'appnexus', params: {} }] }, { bids: [{ bidder: 'rubicon', params: {} }] }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // Each ad unit should have trustedServer added for (const unit of adUnits) { - const hasTsBidder = unit.bids.some((b: any) => b.bidder === 'trustedServer'); + const hasTsBidder = unit.bids.some((b: TestBid) => b.bidder === 'trustedServer'); expect(hasTsBidder).toBe(true); } - const trustedServerBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer'); + const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: {} }); - expect(adUnits[0].bids.map((b: any) => b.bidder)).toEqual(['trustedServer']); - expect(adUnits[1].bids.map((b: any) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[1].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); // Should call through to original requestBids expect(mockRequestBids).toHaveBeenCalled(); @@ -602,9 +658,9 @@ describe('prebid/installPrebidNpm', () => { const pbjs = installPrebidNpm(); const adUnits = [{ bids: [{ bidder: 'trustedServer', params: {} }] }]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsCount = adUnits[0].bids.filter((b: any) => b.bidder === 'trustedServer').length; + const tsCount = adUnits[0].bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; expect(tsCount).toBe(1); }); @@ -619,15 +675,15 @@ describe('prebid/installPrebidNpm', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const trustedServerBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer'); + const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); expect(trustedServerBid).toBeDefined(); expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, }); - expect(adUnits[0].bids.map((b: any) => b.bidder)).toEqual(['trustedServer']); + expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); }); it('preserves captured bidder params when requestBids runs twice on the same ad unit', () => { @@ -643,16 +699,16 @@ describe('prebid/installPrebidNpm', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // Second auction (refresh/re-auction) with the SAME ad unit object: the // server-side bidder entries were already pruned, so the shim must not // overwrite the captured params with an empty object. - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); const trustedServerBid = adUnits[0].bids.find( - (b: any) => b.bidder === 'trustedServer' - ) as any; + (b: TestBid) => b.bidder === 'trustedServer' + ) as TestBid; expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -662,8 +718,8 @@ describe('prebid/installPrebidNpm', () => { it('adds bids array to ad units that have none', () => { const pbjs = installPrebidNpm(); - const adUnits = [{ code: 'div-1' }] as any[]; - pbjs.requestBids({ adUnits } as any); + const adUnits = [{ code: 'div-1' }] as TestAdUnit[]; + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); expect(adUnits[0].bids).toHaveLength(1); expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); @@ -684,12 +740,12 @@ describe('prebid/installPrebidNpm', () => { bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid0 = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid0 = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid0.params.zone).toBe('header'); - const tsBid1 = adUnits[1].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid1 = adUnits[1].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid1.params.zone).toBe('fixed_bottom'); }); @@ -703,9 +759,9 @@ describe('prebid/installPrebidNpm', () => { bids: [{ bidder: 'appnexus', params: {} }], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBeUndefined(); }); @@ -713,9 +769,9 @@ describe('prebid/installPrebidNpm', () => { const pbjs = installPrebidNpm(); const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBeUndefined(); }); @@ -733,16 +789,16 @@ describe('prebid/installPrebidNpm', () => { }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - let tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + let tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBe('header'); expect(tsBid.params.custom).toBe('keep'); delete adUnits[0].mediaTypes.banner.name; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.zone).toBeUndefined(); expect(tsBid.params.custom).toBe('keep'); }); @@ -750,11 +806,11 @@ describe('prebid/installPrebidNpm', () => { it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { const pbjs = installPrebidNpm(); - mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as any[]; - pbjs.requestBids({} as any); + mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; + pbjs.requestBids({} as RequestBidsArg); - const hasTsBidder = (mockPbjs.adUnits[0] as any).bids.some( - (b: any) => b.bidder === 'trustedServer' + const hasTsBidder = (mockPbjs.adUnits[0].bids ?? []).some( + (b: TestBid) => b.bidder === 'trustedServer' ); expect(hasTsBidder).toBe(true); }); @@ -774,7 +830,9 @@ describe('prebid/installPrebidNpm', () => { ]); const pbjs = installPrebidNpm(); - pbjs.requestBids({ adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }] } as any); + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; expect(cookieValue).toBeDefined(); @@ -797,7 +855,9 @@ describe('prebid/installPrebidNpm', () => { mockGetUserIdsAsEids.mockReturnValue([]); const pbjs = installPrebidNpm(); - pbjs.requestBids({ adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }] } as any); + pbjs.requestBids({ + adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], + } as unknown as RequestBidsArg); expect(document.cookie).toBe(''); }); @@ -812,15 +872,15 @@ describe('prebid/installPrebidNpm with server-injected config', () => { mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete (window as any).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); afterEach(() => { - delete (window as any).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); it('reads timeout and debug from window.__tsjs_prebid', () => { - (window as any).__tsjs_prebid = { timeout: 1500, debug: true }; + testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; installPrebidNpm(); @@ -830,7 +890,7 @@ describe('prebid/installPrebidNpm with server-injected config', () => { }); it('explicit config overrides server-injected values', () => { - (window as any).__tsjs_prebid = { timeout: 1500, debug: true }; + testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; installPrebidNpm({ timeout: 3000, debug: false }); @@ -853,13 +913,13 @@ describe('prebid/installRefreshHandler', () => { mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; mockPbjs.adUnits = []; - (window as any).tsjs = undefined; - delete (window as any).googletag; + testWindow.tsjs = undefined; + delete testWindow.googletag; }); afterEach(() => { - (window as any).tsjs = undefined; - delete (window as any).googletag; + testWindow.tsjs = undefined; + delete testWindow.googletag; }); it('builds refresh ad units from injected slot metadata', () => { @@ -872,11 +932,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -930,11 +990,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'prefix_ad', @@ -975,7 +1035,7 @@ describe('prebid/installRefreshHandler', () => { it('scopes the GPT targeting call to the refreshed slot code', () => { const setTargetingForGPTAsync = vi.fn(); - (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; // Run the bidsBackHandler synchronously so the targeting call fires. mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { opts?.bidsBackHandler?.(); @@ -991,11 +1051,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [headerSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'header_ad', @@ -1021,11 +1081,11 @@ describe('prebid/installRefreshHandler', () => { expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-header']); expect(originalRefresh).toHaveBeenCalledWith([headerSlot], undefined); - delete (mockPbjs as any).setTargetingForGPTAsync; + mockPbjs.setTargetingForGPTAsync = undefined; }); it('includes configured client-side bidders in refresh ad units', () => { - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; // Original publisher ad unit carries a client-side rubicon bid. mockPbjs.adUnits = [ { @@ -1045,11 +1105,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1078,7 +1138,7 @@ describe('prebid/installRefreshHandler', () => { }) ); - delete (window as any).__tsjs_prebid; + delete testWindow.__tsjs_prebid; mockPbjs.adUnits = []; }); @@ -1100,11 +1160,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1146,7 +1206,7 @@ describe('prebid/installRefreshHandler', () => { // publisher's Prebid ad unit is keyed by the inner div_id. The synthetic // refresh code stays the GPT element id (so GPT can match it), while params // and client-side bids are recovered from the injected div_id candidate. - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; mockPbjs.adUnits = [ { code: 'div-ad-x', @@ -1165,11 +1225,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'x_ad', @@ -1205,7 +1265,7 @@ describe('prebid/installRefreshHandler', () => { }) ); - delete (window as any).__tsjs_prebid; + delete testWindow.__tsjs_prebid; mockPbjs.adUnits = []; }); @@ -1233,11 +1293,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1295,12 +1355,12 @@ describe('prebid/installRefreshHandler', () => { getSlots: vi.fn(() => [gptSlot]), }; const setTargetingForGPTAsync = vi.fn(); - (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; - (window as any).googletag = { + mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { + testWindow.tsjs = { adSlots: [ { id: 'homepage_header_ad', @@ -1365,11 +1425,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { adInitRefreshInProgress: true }; + testWindow.tsjs = { adInitRefreshInProgress: true }; installRefreshHandler(750); pubads.refresh([gptSlot]); @@ -1390,11 +1450,11 @@ describe('prebid/installRefreshHandler', () => { refresh: originalRefresh, getSlots: vi.fn(() => [gptSlot]), }; - (window as any).googletag = { + testWindow.googletag = { cmd: { push: (fn: () => void) => fn() }, pubads: () => pubads, }; - (window as any).tsjs = { adInitRefreshInProgress: false }; + testWindow.tsjs = { adInitRefreshInProgress: false }; installRefreshHandler(750); pubads.refresh([gptSlot]); @@ -1412,16 +1472,16 @@ describe('prebid/client-side bidders', () => { mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); // By default the manifest declares all adapters compiled in. - (window as any).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - delete (window as any).__tsjs_prebid; + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + delete testWindow.__tsjs_prebid; }); afterEach(() => { - delete (window as any).__tsjs_prebid; + delete testWindow.__tsjs_prebid; }); it('excludes client-side bidders from trustedServer bidderParams', () => { - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const pbjs = installPrebidNpm(); @@ -1434,9 +1494,9 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid).toBeDefined(); // rubicon should NOT be in bidderParams — it runs client-side expect(tsBid.params.bidderParams).toEqual({ @@ -1446,7 +1506,7 @@ describe('prebid/client-side bidders', () => { }); it('preserves client-side bidder bids as standalone entries', () => { - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const pbjs = installPrebidNpm(); @@ -1458,17 +1518,17 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // rubicon bid should remain untouched as a standalone entry - const rubiconBid = adUnits[0].bids.find((b: any) => b.bidder === 'rubicon') as any; + const rubiconBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon') as TestBid; expect(rubiconBid).toBeDefined(); expect(rubiconBid.params).toEqual({ accountId: 'abc' }); - expect(adUnits[0].bids.find((b: any) => b.bidder === 'appnexus')).toBeUndefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); it('handles multiple client-side bidders', () => { - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; const pbjs = installPrebidNpm(); @@ -1481,18 +1541,18 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; // Only appnexus should be in bidderParams expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, }); // Both client-side bidders should remain - expect(adUnits[0].bids.find((b: any) => b.bidder === 'rubicon')).toBeDefined(); - expect(adUnits[0].bids.find((b: any) => b.bidder === 'openx')).toBeDefined(); - expect(adUnits[0].bids.find((b: any) => b.bidder === 'appnexus')).toBeUndefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'rubicon')).toBeDefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'openx')).toBeDefined(); + expect(adUnits[0].bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); it('behaves normally when no client-side bidders are configured', () => { @@ -1507,9 +1567,9 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -1517,7 +1577,7 @@ describe('prebid/client-side bidders', () => { }); it('behaves normally when client-side bidders list is empty', () => { - (window as any).__tsjs_prebid = { clientSideBidders: [] }; + testWindow.__tsjs_prebid = { clientSideBidders: [] }; const pbjs = installPrebidNpm(); @@ -1529,9 +1589,9 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -1539,7 +1599,7 @@ describe('prebid/client-side bidders', () => { }); it('still injects trustedServer when all bidders are client-side', () => { - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; const pbjs = installPrebidNpm(); @@ -1551,21 +1611,21 @@ describe('prebid/client-side bidders', () => { ], }, ]; - pbjs.requestBids({ adUnits } as any); + pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // trustedServer should still be present (even with empty bidderParams) - const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any; + const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; expect(tsBid).toBeDefined(); expect(tsBid.params.bidderParams).toEqual({}); }); it('logs error when a client-side bidder has no adapter in the external bundle', () => { // rubicon is compiled into the external bundle, but openx is not - (window as any).__tsjs_prebid_bundle = { + testWindow.__tsjs_prebid_bundle = { ...DEFAULT_BUNDLE_MANIFEST, adapters: ['rubicon'], }; - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -1591,12 +1651,12 @@ describe('prebid/client-side bidders', () => { expect(hasRubiconError).toBe(false); errorSpy.mockRestore(); - (window as any).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); it('warns when the external bundle stamped no adapter manifest', () => { - delete (window as any).__tsjs_prebid_bundle; - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + delete testWindow.__tsjs_prebid_bundle; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -1608,11 +1668,11 @@ describe('prebid/client-side bidders', () => { expect(hasManifestWarn).toBe(true); warnSpy.mockRestore(); - (window as any).__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; + testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; }); it('does not log errors when all client-side bidders have adapters', () => { - (window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] }; + testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts index 9ade23382..881a4515f 100644 --- a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts +++ b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + import { createBeaconGuard, BeaconGuardConfig } from '../../src/shared/beacon_guard'; describe('Beacon Guard', () => { @@ -14,12 +15,10 @@ describe('Beacon Guard', () => { originalFetch = window.fetch; // Create spies that simulate real sendBeacon/fetch behaviour - sendBeaconSpy = vi.fn((_url: string | URL, _data?: BodyInit | null) => true); + sendBeaconSpy = vi.fn(() => true); navigator.sendBeacon = sendBeaconSpy; - fetchSpy = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve(new Response('', { status: 200 })) - ); + fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); window.fetch = fetchSpy; config = { From 5a4ef23ec67dea7ce6c9247af219257662a163d9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 27 Jul 2026 13:52:21 +0530 Subject: [PATCH 125/494] Rename /__ts/page-bids to /_ts/page-bids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA re-auction endpoint was the only internal route using a double-underscore prefix; every other internal path lives under /_ts/. Move it to /_ts/page-bids and switch the tsjs client fetch to match. A browser runs whichever tsjs bundle it was already served, so pages loaded before the rename — and cached bundles — keep requesting the old path. On a SPA that path delivers ads for in-session navigations, so /__ts/page-bids stays registered to the same handler as a transition alias until those bundles age out. Both paths are defined once in core as PAGE_BIDS_PATH and PAGE_BIDS_LEGACY_PATH so removal touches one const plus its four registrations. handle_page_bids logs an info line when the alias serves a gate-passed request. Without it nothing in the app distinguishes the two paths, so the "no remaining legacy traffic" precondition for removing the alias would only be answerable from edge access logs. Route coverage was missing for the page-bids GET registrations on Cloudflare and Spin, where GET and OPTIONS are registered separately and the preflight-denial parity test does not imply the GET side is wired. Mutation testing confirmed a broken registration previously passed every suite. The route-table assertions pin literal paths rather than the consts, since looking a route up by the same const it was registered with still passes when the const's value changes. --- crates/trusted-server-adapter-axum/src/app.rs | 18 +++- .../tests/routes.rs | 10 ++ .../src/app.rs | 56 ++++++----- .../tests/routes.rs | 10 ++ .../trusted-server-adapter-fastly/src/app.rs | 62 +++++++++++- crates/trusted-server-adapter-spin/src/app.rs | 26 +++-- .../tests/routes.rs | 50 ++++++++++ .../src/auction/endpoints.rs | 8 +- .../src/auction/orchestrator.rs | 4 +- .../src/auction/telemetry.rs | 2 +- .../src/integrations/gpt.rs | 2 +- crates/trusted-server-core/src/publisher.rs | 95 +++++++++++++++++-- .../tests/parity.rs | 38 ++++---- .../lib/src/integrations/gpt/index.ts | 4 +- .../test/integrations/gpt/spa_hook.test.ts | 6 +- 15 files changed, 311 insertions(+), 80 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..d4ec91047 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -19,8 +19,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -140,7 +140,7 @@ where // --------------------------------------------------------------------------- /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, -/// `/__ts/page-bids`, and the publisher fallback). +/// `/_ts/page-bids`, and the publisher fallback). /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented @@ -279,7 +279,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 12] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -328,7 +328,15 @@ fn named_routes() -> [NamedRoute; 12] { // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS // preflight guard for this side-effecting endpoint. NamedRoute { - path: "/__ts/page-bids", + path: PAGE_BIDS_PATH, + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::PageBids, + }, + // Deprecated double-underscore alias, kept so tsjs bundles served before + // the `/_ts/page-bids` rename keep getting ads on SPA navigations until + // they age out of browser caches. See `PAGE_BIDS_LEGACY_PATH`. + NamedRoute { + path: PAGE_BIDS_LEGACY_PATH, primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d990..4b15b4c6a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -77,6 +77,16 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), + // 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 + // actual URL the tsjs client fetches — asserting a const against itself + // would still pass if the const's value changed out from under the + // client. + ("GET", "/_ts/page-bids"), + ("OPTIONS", "/_ts/page-bids"), + ("GET", "/__ts/page-bids"), + ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f6..b3694fa5a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -21,8 +21,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -125,7 +126,7 @@ fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, -/// `/__ts/page-bids`, and the publisher fallback). +/// `/_ts/page-bids`, and the publisher fallback). /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented @@ -480,28 +481,6 @@ fn build_router(state: &Arc) -> RouterService { .await }), ) - // SPA re-auction endpoint. The OPTIONS preflight for this - // side-effecting GET is denied so the GET handler's `X-TSJS-Page-Bids` - // gate stays trustworthy. - .route( - "/__ts/page-bids", - Method::OPTIONS, - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(page_bids_preflight_denied()) - }), - ) - .get( - "/__ts/page-bids", - make_handler(Arc::clone(&state), |s, services, req| async move { - let ec_context = build_ec_context(&s.settings, &services, &req); - let auction = AuctionDispatch { - orchestrator: &s.orchestrator, - slots: s.settings.creative_opportunity_slots(), - registry: None, - }; - handle_page_bids(&s.settings, &services, None, auction, &ec_context, req).await - }), - ) .get( "/first-party/proxy", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -533,6 +512,33 @@ fn build_router(state: &Arc) -> RouterService { }), ); + // SPA re-auction endpoint, registered on the canonical path and on the + // deprecated `PAGE_BIDS_LEGACY_PATH` double-underscore alias. The alias + // keeps tsjs bundles served before the `/_ts/page-bids` rename getting + // ads on SPA navigations until they age out of browser caches. + // + // The OPTIONS preflight is denied on both so the GET handler's + // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the + // preflight fall through to a permissive origin would reopen exactly + // the cross-site hole the canonical path closes. + let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { + let ec_context = build_ec_context(&s.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &s.orchestrator, + slots: s.settings.creative_opportunity_slots(), + registry: None, + }; + handle_page_bids(&s.settings, &services, None, auction, &ec_context, req).await + }); + let page_bids_preflight = + make_handler(Arc::clone(&state), |_s, _services, _req| async move { + Ok(page_bids_preflight_denied()) + }); + for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { + router = router.route(path, Method::GET, page_bids.clone()); + router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); + } + let legacy_admin_deny = make_handler(Arc::clone(&state), |_s, _services, _req| async move { Ok(legacy_admin_alias_denied()) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index df2781945..d5eb98451 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -216,6 +216,16 @@ fn all_explicit_routes_are_registered() { ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("POST", "/auction"), + // 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 + // actual URL the tsjs client fetches — asserting a const against itself + // would still pass if the const's value changed out from under the + // client. + ("GET", "/_ts/page-bids"), + ("OPTIONS", "/_ts/page-bids"), + ("GET", "/__ts/page-bids"), + ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235b..8b7de4000 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -116,8 +116,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -1083,7 +1083,17 @@ const NAMED_ROUTES: &[NamedRoute] = &[ // GET runs the SPA re-auction; OPTIONS is denied in-handler as a CORS // preflight guard for this side-effecting endpoint. NamedRoute { - path: "/__ts/page-bids", + path: PAGE_BIDS_PATH, + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::PageBids, + }, + // Deprecated double-underscore alias. tsjs bundles served before the + // `/_ts/page-bids` rename keep requesting this path from already-loaded + // pages and browser caches; dropping it would strand SPA navigations + // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`; + // removal is tracked by IABTechLab/trusted-server#970. + NamedRoute { + path: PAGE_BIDS_LEGACY_PATH, primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, @@ -1211,8 +1221,8 @@ mod tests { use std::sync::Arc; use super::{ - AppState, NAMED_ROUTES, NamedRouteHandler, TrustedServerApp, build_state_from_settings, - startup_error_router, + AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, + TrustedServerApp, build_state_from_settings, startup_error_router, }; use bytes::Bytes; use edgezero_core::body::Body; @@ -1624,6 +1634,48 @@ mod tests { } } + #[test] + fn page_bids_serves_canonical_path_and_deprecated_alias() { + // The SPA re-auction endpoint lives at the canonical single-underscore + // `/_ts/page-bids`, matching every other internal route. The deprecated + // `/__ts/page-bids` alias must stay registered to the same handler with + // the same methods until pre-rename tsjs bundles age out of browser + // caches — dropping it would leave those clients without ads on SPA + // navigations. + // + // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`. + // Looking a route up by the same const it was registered with is + // tautological: it keeps passing if the const's value changes, which is + // exactly the break that would silently desync the server from the tsjs + // client's hardcoded fetch path. Pin the consts to their literals too so + // a rename has to be deliberate. + assert_eq!( + PAGE_BIDS_PATH, "/_ts/page-bids", + "canonical page-bids path must match the path tsjs fetches" + ); + assert_eq!( + PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids", + "legacy alias must match the path pre-rename tsjs bundles fetch" + ); + + for path in ["/_ts/page-bids", "/__ts/page-bids"] { + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == path) + .unwrap_or_else(|| panic!("{path} should be registered")); + + assert!( + matches!(route.handler, NamedRouteHandler::PageBids), + "{path} must map to the page-bids handler" + ); + assert_eq!( + route.primary_methods, + &[Method::GET, Method::OPTIONS], + "{path} must handle GET and OPTIONS directly, not fall through to the publisher" + ); + } + } + #[test] fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { // Regression for the credential-leak finding: with a production-shaped diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce74..9f9d3235f 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -20,8 +20,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -141,7 +142,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -150,7 +151,8 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), ("/auction", &[Method::POST]), - ("/__ts/page-bids", &[Method::GET, Method::OPTIONS]), + (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), + (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), @@ -322,7 +324,7 @@ fn health_response() -> Response { } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, -/// `/__ts/page-bids`, and the publisher fallback). +/// `/_ts/page-bids`, and the publisher fallback). /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented @@ -541,7 +543,7 @@ fn build_router(state: &Arc) -> RouterService { } }; - // GET /__ts/page-bids — SPA re-auction endpoint. + // GET /_ts/page-bids — SPA re-auction endpoint. let s = Arc::clone(&state); let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); @@ -562,7 +564,7 @@ fn build_router(state: &Arc) -> RouterService { } }; - // OPTIONS /__ts/page-bids — deny the CORS preflight for this + // OPTIONS /_ts/page-bids — deny the CORS preflight for this // side-effecting GET so the `X-TSJS-Page-Bids` gate stays trustworthy. let page_bids_options_handler = |_ctx: RequestContext| async { Ok::(page_bids_preflight_denied()) @@ -731,9 +733,15 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) .post("/auction", auction_handler) - .get("/__ts/page-bids", page_bids_handler) + .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) + // Deprecated double-underscore alias, kept so tsjs bundles served + // before the `/_ts/page-bids` rename keep getting ads on SPA + // navigations until they age out of browser caches. See + // `PAGE_BIDS_LEGACY_PATH`. + .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler) .route( - "/__ts/page-bids", + PAGE_BIDS_LEGACY_PATH, Method::OPTIONS, page_bids_options_handler, ) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 9b96dbd70..2e1f0f6e5 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -330,6 +330,56 @@ async fn auction_is_routed() { assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); } +/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on +/// both the canonical path and its deprecated `/__ts/` alias. +/// +/// The alias is what pre-rename tsjs bundles still request, and on a SPA that +/// path is what delivers ads for in-session navigations — so a dropped or +/// misspelled registration silently costs revenue rather than erroring loudly. +/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity +/// test does not imply the `GET` side is wired. +/// +/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`: +/// this pins the actual URL the client fetches, which asserting a const against +/// itself would not. +/// +/// These test settings configure no creative opportunities, so the handler's own +/// deterministic answer is a 404 `Creative opportunities not configured`. That +/// body is the anchor: an unregistered path would instead fall through to the +/// publisher fallback and attempt an outbound fetch to the (nonexistent) test +/// origin, which cannot produce this message. A bare `!= 404` check would be +/// wrong here — the handler legitimately returns 404 under this config. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn page_bids_get_is_routed_on_canonical_path_and_alias() { + let mut responses = Vec::new(); + + for path in ["/_ts/page-bids", "/__ts/page-bids"] { + let req = request_builder() + .method("GET") + .uri(path) + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + let status = resp.status().as_u16(); + let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default()) + .into_owned(); + + assert!( + body.contains("Creative opportunities not configured"), + "GET {path} must reach the page-bids handler, \ + got status {status} body {body:?}" + ); + + responses.push((status, body)); + } + + assert_eq!( + responses[0], responses[1], + "the deprecated alias must answer identically to the canonical path" + ); +} + // --------------------------------------------------------------------------- // Publisher fallback method parity — non-GET/POST methods must reach the // publisher origin fallback (not a router-level 405), matching Fastly/Axum. diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 1b0ced7a7..1d959c05d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -86,7 +86,7 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// callers** (e.g. slim-Prebid, native apps, server-to-server integrations). /// It is **not** the intended path for scroll or GPT refresh events. /// -/// **SPA navigation** is handled by `GET /__ts/page-bids`: the client-side SPA +/// **SPA navigation** is handled by `GET /_ts/page-bids`: the client-side SPA /// hook (`installSpaAuctionHook`) intercepts `pushState`/`replaceState`/`popstate` /// events and calls that endpoint to fetch fresh slots and bids for each new /// route, then invokes `window.tsjs.adInit()` with the updated data. @@ -171,7 +171,7 @@ pub async fn handle_auction( let consent_context = ec_context.consent().clone(); // Server-side auction consent gate. The publisher-navigation and - // `/__ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that + // `/_ts/page-bids` paths fail closed for GDPR/unknown jurisdictions that // lack effective TCF Purpose 1. `/auction` is the programmatic entry point // for the same server-side auction, so it must gate identically: returning // a no-bid response here prevents outbound PBS/APS calls and the forwarding @@ -234,7 +234,7 @@ pub async fn handle_auction( // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` // only strips on TCF/GDPR signals. This matches the publisher and - // `/__ts/page-bids` paths, which also resolve client EIDs only when + // `/_ts/page-bids` paths, which also resolve client EIDs only when // `ec_id.is_some()`. let client_eids = if ec_id.is_some() { resolve_client_auction_eids( @@ -646,7 +646,7 @@ mod tests { // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run // a server-side auction. The /auction endpoint must short-circuit to a // no-bid response before dispatching to any provider — matching the - // publisher-navigation and /__ts/page-bids paths. + // publisher-navigation and /_ts/page-bids paths. let settings = create_test_settings(); let config = AuctionConfig { enabled: true, diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bf9ecad7b..71afa7c50 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -367,7 +367,7 @@ impl AuctionOrchestrator { // restore nurl/burl/ad_id and PBS cache fields from the collected SSP // responses. The dispatched collect path already does this; the // synchronous mediation path used by POST /auction and - // /__ts/page-bids must match or mediated cache bids lose the metadata + // /_ts/page-bids must match or mediated cache bids lose the metadata // needed for creative rendering and win/billing beacons. let mediator_resp = mediator .parse_response_with_context( @@ -1779,7 +1779,7 @@ mod tests { // run_parallel_mediation must parse the mediator response via // parse_response_with_context so cache/nurl fields restored from SSP // responses survive the synchronous mediation path (POST /auction, - // /__ts/page-bids), matching the dispatched collect path. + // /_ts/page-bids), matching the dispatched collect path. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); // bidder send_async stub.push_response(200, b"{}".to_vec()); // mediator send_async diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index d63445369..02752c6f9 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -25,7 +25,7 @@ const DYNAMIC_SEGMENT_REPLACEMENT: &str = ":id"; pub enum AuctionSource { /// Initial publisher navigation using server-side ad templates. InitialNavigation, - /// SPA navigation through `GET /__ts/page-bids`. + /// SPA navigation through `GET /_ts/page-bids`. SpaNavigation, /// Explicit `POST /auction` API. AuctionApi, diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index e21058a21..7783332d9 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -483,7 +483,7 @@ impl IntegrationHeadInjector for GptIntegration { /// for GPT refresh events, runs client-side auctions, and sets targeting for /// subsequent impressions. SPA navigation is handled separately by /// `installSpaAuctionHook()` in the GPT bundle, which re-runs the server-side - /// auction via `GET /__ts/page-bids` on pushState / replaceState / popstate + /// auction via `GET /_ts/page-bids` on pushState / replaceState / popstate /// route changes (see `auction/endpoints.rs`). /// The `POST /auction` endpoint is not involved in scroll or refresh flows. fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d6e9d2ee..82767e323 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2276,7 +2276,27 @@ fn is_supported_content_encoding(encoding: &str) -> bool { matches!(encoding, "" | "identity" | "gzip" | "deflate" | "br") } -/// Same-origin gate for `/__ts/page-bids`. +/// Canonical URL path of the SPA re-auction endpoint. +/// +/// Lives in the internal `/_ts/` namespace shared by every other Trusted +/// Server route. Adapters register this path; the tsjs SPA hook fetches it. +pub const PAGE_BIDS_PATH: &str = "/_ts/page-bids"; + +/// Deprecated double-underscore alias of [`PAGE_BIDS_PATH`]. +/// +/// The endpoint originally shipped as `/__ts/page-bids`, the only internal path +/// using a `__` prefix. Renaming it is atomic on the server, but a browser runs +/// whichever tsjs bundle it was already served: pages loaded before the rename — +/// and cached bundles — keep requesting this path, and on a SPA that path is what +/// delivers ads for in-session navigations. Adapters route it to the same handler +/// so those clients keep working. +/// +/// Removal is tracked by IABTechLab/trusted-server#970: drop this const and its +/// four adapter registrations once access logs show no remaining traffic on the +/// legacy path. +pub const PAGE_BIDS_LEGACY_PATH: &str = "/__ts/page-bids"; + +/// Same-origin gate for `/_ts/page-bids`. /// /// The endpoint is a side-effecting GET: it dispatches real PBS/APS auctions /// and forwards request-derived signals (IP, UA, geo, consent) to partners. @@ -2306,7 +2326,7 @@ fn page_bids_request_allowed(req: &Request) -> bool { } /// Builds the `403 Forbidden` returned when the side-effecting -/// `/__ts/page-bids` endpoint refuses a request — both the CORS preflight +/// `/_ts/page-bids` endpoint refuses a request — both the CORS preflight /// (`OPTIONS`) and the GET cross-site gate ([`page_bids_request_allowed`]) /// return this single denial shape. /// @@ -2315,7 +2335,8 @@ fn page_bids_request_allowed(req: &Request) -> bool { /// preflight; letting `OPTIONS` fall through to the publisher origin (which may /// return permissive CORS) would defeat that, allowing a cross-site page to /// trigger real PBS/APS auctions from a visitor's browser. Every adapter returns -/// this same response for `OPTIONS /__ts/page-bids`. +/// this same response for `OPTIONS /_ts/page-bids` and for its deprecated +/// `/__ts/page-bids` alias. pub fn page_bids_preflight_denied() -> Response { let mut response = Response::new(EdgeBody::from("Forbidden")); *response.status_mut() = StatusCode::FORBIDDEN; @@ -2340,7 +2361,7 @@ fn normalize_page_bids_path(raw: &str) -> String { } } -/// Handle `GET /__ts/page-bids?path=` — server-side auction for SPA navigation. +/// Handle `GET /_ts/page-bids?path=` — server-side auction for SPA navigation. /// /// Matches creative opportunity slots for the given path, runs a server-side /// auction (APS + PBS), and returns the slot definitions and winning bids as JSON. @@ -2380,6 +2401,22 @@ pub async fn handle_page_bids( return Ok(page_bids_preflight_denied()); } + // Deprecation signal for the transition alias. Logged only after the + // cross-site gate passes, so the count reflects genuine SPA clients still + // running a pre-rename tsjs bundle rather than anything a third-party page + // can inflate. This is the only in-app signal that + // `PAGE_BIDS_LEGACY_PATH` is still in use — the removal precondition in + // IABTechLab/trusted-server#970 is "no remaining traffic on the legacy + // path", which is otherwise only answerable from edge access logs. The line + // is self-limiting: it goes silent as old bundles age out, which is exactly + // the condition being waited on. + if req.uri().path() == PAGE_BIDS_LEGACY_PATH { + log::info!( + "page-bids: served deprecated alias {PAGE_BIDS_LEGACY_PATH} \ + (pre-rename tsjs bundle); see IABTechLab/trusted-server#970" + ); + } + let path_param = req .uri() .query() @@ -4947,11 +4984,15 @@ mod tests { } fn make_page_bids_request(path: &str) -> Request { + make_page_bids_request_on(PAGE_BIDS_PATH, path) + } + + /// Builds a page-bids request against an explicit endpoint path, so the + /// canonical route and its deprecated alias can be compared directly. + fn make_page_bids_request_on(endpoint: &str, path: &str) -> Request { let mut req = Request::builder() .method(Method::GET) - .uri(format!( - "https://test-publisher.com/_ts/page-bids?path={path}" - )) + .uri(format!("https://test-publisher.com{endpoint}?path={path}")) .body(EdgeBody::empty()) .expect("should build test request"); // Pass the same-origin gate the way a browser fetch from the @@ -5002,6 +5043,46 @@ mod tests { .expect("should return ok response") } + /// The deprecated `/__ts/page-bids` alias must be handled identically to + /// the canonical path — same status, same JSON body. + /// + /// The alias exists so pre-rename tsjs bundles keep getting ads on SPA + /// navigations. If the handler ever varied its output by request path + /// (slot matching reads the `path` *query parameter*, not the endpoint + /// path), those clients would silently get different results from the + /// ones on the canonical route. + #[tokio::test] + async fn deprecated_alias_response_matches_canonical_path() { + let settings = settings_with_co(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + + let canonical = run_page_bids_response( + &settings, + &orchestrator, + &article_slot(), + make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), + ) + .await; + let alias = run_page_bids_response( + &settings, + &orchestrator, + &article_slot(), + make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), + ) + .await; + + assert_eq!( + canonical.status(), + alias.status(), + "alias must return the same status as the canonical path" + ); + assert_eq!( + canonical.into_body().into_bytes(), + alias.into_body().into_bytes(), + "alias must return the same body as the canonical path" + ); + } + #[tokio::test] async fn cross_site_fetch_metadata_is_rejected() { let settings = settings_with_co(); diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index e85b1d8d1..acf7f5f4b 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -696,28 +696,34 @@ async fn auction_not_challenged_by_auth_parity() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn page_bids_options_preflight_denied_parity() { - // OPTIONS /__ts/page-bids is a CORS preflight to a side-effecting endpoint. + // OPTIONS /_ts/page-bids is a CORS preflight to a side-effecting endpoint. // Every adapter must refuse it with 403 rather than proxy it to the origin: // a permissive origin preflight would let a cross-site page defeat the GET // handler's `X-TSJS-Page-Bids` gate and trigger real auctions in a visitor's // browser. The denial is unconditional (independent of creative-opportunity // configuration), so all adapters must agree on 403. - let (axum_status, _) = axum_options("/__ts/page-bids").await; - let (cf_status, _) = cf_options("/__ts/page-bids").await; - let (spin_status, _) = spin_options("/__ts/page-bids").await; + // + // The deprecated `/__ts/page-bids` alias routes to the same handler, so it + // must deny the preflight identically — an alias that fell through to the + // origin would reopen the hole the canonical path closes. + for path in ["/_ts/page-bids", "/__ts/page-bids"] { + let (axum_status, _) = axum_options(path).await; + let (cf_status, _) = cf_options(path).await; + let (spin_status, _) = spin_options(path).await; - assert_eq!( - axum_status, 403, - "Axum OPTIONS /__ts/page-bids must be denied with 403, got {axum_status}" - ); - assert_eq!( - cf_status, 403, - "Cloudflare OPTIONS /__ts/page-bids must be denied with 403, got {cf_status}" - ); - assert_eq!( - spin_status, 403, - "Spin OPTIONS /__ts/page-bids must be denied with 403, got {spin_status}" - ); + assert_eq!( + axum_status, 403, + "Axum OPTIONS {path} must be denied with 403, got {axum_status}" + ); + assert_eq!( + cf_status, 403, + "Cloudflare OPTIONS {path} must be denied with 403, got {cf_status}" + ); + assert_eq!( + spin_status, 403, + "Spin OPTIONS {path} must be denied with 403, got {spin_status}" + ); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index ca4689684..85e27b506 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -696,7 +696,7 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise * * Patches `history.pushState` and `history.replaceState`, and listens to * `popstate`, so that after each client-side route change the trusted server - * fetches fresh slots + bids from `/__ts/page-bids?path=`, updates + * fetches fresh slots + bids from `/_ts/page-bids?path=`, updates * `window.tsjs.adSlots` / `window.tsjs.bids`, and calls `window.tsjs.adInit()`. * * Idempotent: guarded by `window.tsjs.spaHookInstalled` so multiple calls are safe. @@ -728,7 +728,7 @@ export function installSpaAuctionHook(): void { inflight = controller; try { - const res = await fetch(`/__ts/page-bids?path=${encodeURIComponent(path)}`, { + const res = await fetch(`/_ts/page-bids?path=${encodeURIComponent(path)}`, { credentials: 'include', // Non-simple header doubles as a CSRF token: the server rejects // requests that carry neither same-origin Fetch Metadata nor this diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defcb..7dc29989c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -78,7 +78,7 @@ describe('installSpaAuctionHook', () => { await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Fnext-page', + '/_ts/page-bids?path=%2Fnext-page', expect.objectContaining({ credentials: 'include', headers: { 'X-TSJS-Page-Bids': '1' }, @@ -223,7 +223,7 @@ describe('installSpaAuctionHook', () => { history.replaceState({}, '', '/replaced'); await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Freplaced', + '/_ts/page-bids?path=%2Freplaced', expect.objectContaining({ credentials: 'include' }) ); }); @@ -241,7 +241,7 @@ describe('installSpaAuctionHook', () => { window.dispatchEvent(new PopStateEvent('popstate')); await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Fpopped', + '/_ts/page-bids?path=%2Fpopped', expect.objectContaining({ credentials: 'include' }) ); }); From 4ef2de6db5a8ac926fdde1b93ec0b127483adae4 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 14:07:32 -0500 Subject: [PATCH 126/494] Preserve Prebid ad units across GPT refreshes --- .../lib/src/integrations/prebid/index.ts | 239 +++++++- .../test/integrations/prebid/index.test.ts | 509 ++++++++++++++++++ 2 files changed, 731 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 342e4038d..29ac42399 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -238,6 +238,17 @@ type TrustedServerAdUnit = { mediaTypes?: { banner?: TrustedServerBanner }; bids?: TrustedServerBid[]; }; +type ClientSideBidSnapshot = { bidder: string; params: Record }; +type PublisherAdUnitSnapshot = { + bidderParams: Record>; + clientSideBids: ClientSideBidSnapshot[]; + zone?: string; +}; +type PublisherDeliveryContext = { remainingCodes: Set }; + +let publisherAdUnitSnapshots = new Map(); +let syntheticRefreshAdUnits = new WeakSet(); +const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -373,6 +384,17 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { * code in order and return the first matching ad unit, so container-backed slots * still recover the publisher's configured params and bidders. */ +function findRefreshSnapshot( + candidateCodes: Array +): PublisherAdUnitSnapshot | undefined { + for (const code of candidateCodes) { + if (!code) continue; + const snapshot = publisherAdUnitSnapshots.get(code); + if (snapshot) return snapshot; + } + return undefined; +} + function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -385,6 +407,89 @@ function findRefreshAdUnit( return undefined; } +function copyParamValue(value: unknown, seen = new WeakMap()): unknown { + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing) return existing; + const copy: unknown[] = []; + seen.set(value, copy); + value.forEach((entry) => copy.push(copyParamValue(entry, seen))); + return copy; + } + + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + + const existing = seen.get(value); + if (existing) return existing; + const copy = Object.create(prototype) as Record; + seen.set(value, copy); + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(copy, key, { + value: copyParamValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return copy; + } + + return value; +} + +function copyParams(params: Record | undefined): Record { + return copyParamValue(params ?? {}) as Record; +} + +function foldedBidderParams( + bid: TrustedServerBid | undefined +): Record> { + const folded = (bid?.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< + string, + Record + >; + return Object.fromEntries( + Object.entries(folded).map(([bidder, params]) => [bidder, copyParams(params)]) + ); +} + +function capturePublisherAdUnitSnapshot( + unit: TrustedServerAdUnit, + clientSideBidders: Set +): PublisherAdUnitSnapshot | undefined { + if (typeof unit.code !== 'string' || unit.code.length === 0) return undefined; + + const rawBidderParams: Record> = {}; + const clientSideBids: ClientSideBidSnapshot[] = []; + let existingTsBid: TrustedServerBid | undefined; + + const bids = Array.isArray(unit.bids) ? unit.bids : []; + for (const bid of bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + existingTsBid ??= bid; + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + continue; + } + rawBidderParams[bid.bidder] = copyParams(bid.params); + } + + const bidderParams = + Object.keys(rawBidderParams).length > 0 ? rawBidderParams : foldedBidderParams(existingTsBid); + const zone = unit.mediaTypes?.banner?.name; + + return { + bidderParams, + clientSideBids, + ...(zone ? { zone } : {}), + }; +} + /** * Collect the configured client-side bidder entries for a refreshing slot. * @@ -399,6 +504,14 @@ function findRefreshAdUnit( function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return snapshot.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })); + } + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); if (clientSideBidders.size === 0) return []; @@ -408,7 +521,7 @@ function clientSideBidsForRefresh( const bids: Array<{ bidder: string; params: Record }> = []; for (const bid of match.bids) { if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: bid.params ?? {} }); + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); } } return bids; @@ -430,6 +543,13 @@ function clientSideBidsForRefresh( function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) + ); + } + const match = findRefreshAdUnit(candidateCodes); if (!match?.bids) return {}; @@ -466,6 +586,50 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + const index = activePublisherDeliveryContexts.lastIndexOf(context); + if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); +} + +function consumeBarePublisherDeliveryContext(): boolean { + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + if (context.remainingCodes.size === 0) continue; + context.remainingCodes.clear(); + return true; + } + return false; +} + +function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { + if (targetSlots.length === 0) return false; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + const coveredCodes: string[] = []; + let allCovered = true; + + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const coveredCode = candidates.find( + (code): code is string => !!code && context.remainingCodes.has(code) + ); + if (!coveredCode) { + allCovered = false; + break; + } + coveredCodes.push(coveredCode); + } + + if (!allCovered) continue; + coveredCodes.forEach((code) => context.remainingCodes.delete(code)); + return true; + } + + return false; +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -502,6 +666,10 @@ function collectAuctionEids(): AuctionEid[] | undefined { * 2. `config` argument — explicit overrides from the publisher's JS */ export function installPrebidNpm(config?: Partial): typeof pbjs { + publisherAdUnitSnapshots = new Map(); + syntheticRefreshAdUnits = new WeakSet(); + activePublisherDeliveryContexts.length = 0; + const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { endpoint: config?.endpoint, @@ -574,9 +742,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; + const isSyntheticRefresh = + adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); + const publisherAdUnitCodes = new Set(); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { + if (!syntheticRefreshAdUnits.has(unit)) { + const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); + if (snapshot && unit.code) { + publisherAdUnitSnapshots.set(unit.code, snapshot); + publisherAdUnitCodes.add(unit.code); + } + } + if (!Array.isArray(unit.bids)) { unit.bids = []; } @@ -660,8 +839,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); - if (typeof originalBidsBack === 'function') { - originalBidsBack.apply(this, args); + if (typeof originalBidsBack !== 'function') return; + if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { + originalBidsBack.apply(this, args as Parameters); + return; + } + + const context: PublisherDeliveryContext = { + remainingCodes: new Set(publisherAdUnitCodes), + }; + // Delivery attribution is intentionally synchronous and ends as soon as + // the publisher's original callback returns. + activePublisherDeliveryContexts.push(context); + try { + originalBidsBack.apply(this, args as Parameters); + } finally { + removePublisherDeliveryContext(context); } }; @@ -745,6 +938,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { const originalRefresh = pubads.refresh.bind(pubads); pubads.refresh = function (slots?: unknown[], opts?: unknown) { + // For bare refresh() calls (no slots arg), get all registered slots from GPT + // so we can auction the same concrete slot list and avoid stale targeting. + const targetSlots = ( + slots ?? + (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? + [] + ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + // One-shot bypass for adInit()'s internal refresh: that refresh delivers // freshly applied server-side targeting to GAM and must not be turned // into a client-side auction (which would clear the TS targeting). @@ -754,13 +955,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - // For bare refresh() calls (no slots arg), get all registered slots from GPT - // so we can auction the same concrete slot list and avoid stale targeting. - const targetSlots = ( - slots ?? - (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? - [] - ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + const isExplicitSlotList = slots !== undefined; + const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; + const isPublisherDeliveryRefresh = isExplicitSlotList + ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) + : consumeBarePublisherDeliveryContext(); + if (isPublisherDeliveryRefresh) { + return originalRefresh(slots, opts); + } if (!targetSlots.length) { return originalRefresh(slots, opts); @@ -770,8 +972,16 @@ export function installRefreshHandler(timeoutMs = 1500): void { const adUnits = targetSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); + const code = refreshSlotElementId(slot) ?? 'refresh-slot'; + // A TS-owned slot may be defined on `${div_id}-container`, so the GPT + // element id used as the synthetic refresh code can differ from the + // inner `div_id` the publisher keyed their ad unit by. Recover from both. + const candidateCodes = [code, injectedSlot?.div_id]; + const snapshot = findRefreshSnapshot(candidateCodes); const zone = - injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); + injectedSlot?.targeting?.[ZONE_KEY] ?? + firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? + snapshot?.zone; const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -779,12 +989,6 @@ export function installRefreshHandler(timeoutMs = 1500): void { DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), }; - - const code = refreshSlotElementId(slot) ?? 'refresh-slot'; - // A TS-owned slot may be defined on `${div_id}-container`, so the GPT - // element id used as the synthetic refresh code can differ from the - // inner `div_id` the publisher keyed their ad unit by. Recover from both. - const candidateCodes = [code, injectedSlot?.div_id]; const tsParams: Record = zone ? { [ZONE_KEY]: zone } : {}; // Carry the publisher's inline server-side (PBS) bidder params captured // on the initial ad unit so refresh/scroll auctions don't drop them. @@ -807,6 +1011,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { // unrelated GPT slots whose targeting this wrapper only cleared for // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); + adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); pbjs.requestBids({ adUnits, bidsBackHandler: () => { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 726f40b49..250bc0f0c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -668,6 +668,15 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('normalizes a truthy non-array bids value without throwing', () => { + const pbjs = installPrebidNpm(); + const adUnits = [{ code: 'example-malformed-slot', bids: { malformed: true } }] as any[]; + + expect(() => pbjs.requestBids({ adUnits } as any)).not.toThrow(); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1403,6 +1412,506 @@ describe('prebid/installRefreshHandler', () => { }); }); +describe('prebid publisher snapshots and delivery refreshes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequestBids.mockReset(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + mockGetUserIdsAsEids.mockReset(); + mockGetUserIdsAsEids.mockReturnValue([]); + mockGetBidAdapter.mockReturnValue({}); + delete (mockPbjs as any).setTargetingForGPTAsync; + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + afterEach(() => { + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + function installGpt(slots: any[]) { + const originalRefresh = vi.fn(); + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => slots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + installRefreshHandler(640); + return { originalRefresh, pubads }; + } + + function refreshAdUnitFromLastRequest(): any { + const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; + return lastCall?.[0]?.adUnits?.[0]; + } + + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const runtimeInstance = 'example-runtime-instance'; + const code = `example-slot-${runtimeInstance}`; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const firstParams = { placement: 'first' }; + const effectiveParams = { placement: 'effective' }; + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { bidder: 'exampleServer', params: firstParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleServer', params: effectiveParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }, + ], + } as any); + effectiveParams.placement = 'changed-after-auction'; + + pubads.refresh([slot]); + + expect(mockPbjs.adUnits).toEqual([]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(refreshAdUnitFromLastRequest()).toEqual({ + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { exampleServer: { placement: 'effective' } }, + zone: 'example-zone', + }, + }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }); + }); + + it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-nested-params-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const serverParams = { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }; + const browserParams = { + groups: [{ values: ['original-value'] }], + }; + + pbjs.requestBids({ + adUnits: [ + { + code, + bids: [ + { bidder: 'exampleServer', params: serverParams }, + { bidder: 'exampleBrowser', params: browserParams }, + ], + }, + ], + } as any); + serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.sizes.push(999); + browserParams.groups[0].values[0] = 'changed-value'; + + pubads.refresh([slot]); + + const expectedBids = [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + exampleServer: { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }, + }, + }, + }, + { + bidder: 'exampleBrowser', + params: { groups: [{ values: ['original-value'] }] }, + }, + ]; + const firstRefreshBids = refreshAdUnitFromLastRequest().bids; + expect(firstRefreshBids).toEqual(expectedBids); + + firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + 'changed-refresh-rule'; + firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); + firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); + }); + + it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { + const code = 'example-dynamic-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + ], + } as any); + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'two' } }, + zone: 'example-zone-two', + }); + }); + + it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { + const slotOne = { + getSlotElementId: () => 'example-code-one', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-code-two', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const globalSlot = { + getSlotElementId: () => 'example-global-code', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code: 'example-code-one', + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + { + code: 'example-code-two', + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + mockPbjs.adUnits = [ + { + code: 'example-global-code', + bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], + }, + ]; + + pubads.refresh([slotOne]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'one' }, + }); + pubads.refresh([slotTwo]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'two' }, + }); + pubads.refresh([globalSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleFallback: { placement: 'global' }, + }); + }); + + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { + const slotOne = { + getSlotElementId: () => 'example-covered-one', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-covered-two-container', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + (window as any).tsjs = { + adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + }; + const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, + { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pubads.refresh([slotOne]); + pubads.refresh([slotTwo]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slotOne.clearTargeting).not.toHaveBeenCalled(); + expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); + }); + + it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh(), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + }); + + it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const unrelatedSlot = { + getSlotElementId: () => 'example-unrelated', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + pubads.refresh([unrelatedSlot]); + pubads.refresh([coveredSlot, unrelatedSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-covered', + 'example-unrelated', + ]); + expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + }); + + it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + const slot = { + getSlotElementId: () => 'example-deferred-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [ + { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([innerSlot]), + } as any); + pubads.refresh([outerSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); + }); + + it('cleans delivery context after a publisher callback throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-callback', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + expect(() => + pbjs.requestBids({ + adUnits: [ + { + code: 'example-throwing-callback', + bids: [{ bidder: 'exampleServer', params: {} }], + }, + ], + bidsBackHandler: () => { + throw new Error('example callback failure'); + }, + } as any) + ).toThrow('example callback failure'); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + }); + + it('completes an internal synthetic refresh once without recursion', () => { + const slot = { + getSlotElementId: () => 'example-independent-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); +}); + describe('prebid/client-side bidders', () => { beforeEach(() => { vi.clearAllMocks(); From a94559839747f432f8aee3d1c636820351fc6722 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:21:19 -0500 Subject: [PATCH 127/494] Handle deferred Prebid delivery refreshes --- .../lib/src/integrations/prebid/index.ts | 101 ++++++++-- .../test/integrations/prebid/index.test.ts | 188 +++++++++++++++++- 2 files changed, 259 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 29ac42399..0432d8695 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -48,6 +48,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; +const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -244,7 +245,12 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { remainingCodes: Set }; +type PublisherDeliveryContext = { + remainingCodes: Set; + retainForTargetedRefresh: boolean; + cleanupTimer?: ReturnType; +}; +type SetTargetingForGptAsync = (...args: unknown[]) => unknown; let publisherAdUnitSnapshots = new Map(); let syntheticRefreshAdUnits = new WeakSet(); @@ -587,15 +593,32 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + if (context.cleanupTimer !== undefined) { + clearTimeout(context.cleanupTimer); + context.cleanupTimer = undefined; + } const index = activePublisherDeliveryContexts.lastIndexOf(context); if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } +function targetingCoversPublisherDeliveryContext( + adUnitCodes: unknown, + context: PublisherDeliveryContext +): boolean { + if (adUnitCodes === undefined) return context.remainingCodes.size > 0; + const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; + return ( + Array.isArray(codes) && + codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) + ); +} + function consumeBarePublisherDeliveryContext(): boolean { for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { const context = activePublisherDeliveryContexts[index]; if (context.remainingCodes.size === 0) continue; context.remainingCodes.clear(); + removePublisherDeliveryContext(context); return true; } return false; @@ -604,30 +627,35 @@ function consumeBarePublisherDeliveryContext(): boolean { function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { if (targetSlots.length === 0) return false; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCodes: string[] = []; - let allCovered = true; - - for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + // Publishers may include GAM-only slots in the same explicit refresh that + // delivers a completed Prebid auction. Attribute the call to delivery when + // any slot is covered, while consuming only the covered codes so an + // unrelated-only refresh still follows the synthetic auction path. + const matches = new Map>(); + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; const coveredCode = candidates.find( (code): code is string => !!code && context.remainingCodes.has(code) ); - if (!coveredCode) { - allCovered = false; - break; - } - coveredCodes.push(coveredCode); + if (!coveredCode) continue; + + const contextMatches = matches.get(context) ?? new Set(); + contextMatches.add(coveredCode); + matches.set(context, contextMatches); + break; } + } - if (!allCovered) continue; + if (matches.size === 0) return false; + for (const [context, coveredCodes] of matches) { coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - return true; + if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); } - - return false; + return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -668,7 +696,7 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); syntheticRefreshAdUnits = new WeakSet(); - activePublisherDeliveryContexts.length = 0; + [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -847,14 +875,43 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const context: PublisherDeliveryContext = { remainingCodes: new Set(publisherAdUnitCodes), + retainForTargetedRefresh: false, + }; + const targetingPbjs = pbjs as unknown as { + setTargetingForGPTAsync?: SetTargetingForGptAsync; }; - // Delivery attribution is intentionally synchronous and ends as soon as - // the publisher's original callback returns. + const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; + let targetingWrapper: SetTargetingForGptAsync | undefined; + if (typeof originalSetTargeting === 'function') { + targetingWrapper = (...targetingArgs: unknown[]) => { + const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); + if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { + context.retainForTargetedRefresh = true; + } + return result; + }; + targetingPbjs.setTargetingForGPTAsync = targetingWrapper; + } + activePublisherDeliveryContexts.push(context); try { originalBidsBack.apply(this, args as Parameters); } finally { - removePublisherDeliveryContext(context); + if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { + targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; + } + if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { + // Some publisher wrappers set targeting in bidsBackHandler, return, + // and schedule the matching GPT refresh shortly afterward. Retain + // this one-shot context only after that targeting signal, with a + // bounded expiry so a later independent refresh remains independent. + context.cleanupTimer = setTimeout( + () => removePublisherDeliveryContext(context), + PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS + ); + } else { + removePublisherDeliveryContext(context); + } } }; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 250bc0f0c..4c88a02c2 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1745,7 +1745,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); - it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1772,15 +1772,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); - expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ - 'example-covered', - 'example-unrelated', - ]); - expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(originalRefresh).toHaveBeenCalledTimes(2); @@ -1788,7 +1784,183 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); - it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-covered-${index}`, + getTargeting: () => [], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: coveredSlots.map((_, index) => ({ + code: `example-covered-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + vi.useFakeTimers(); + try { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-targeted-${index}`, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-targeted-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let refreshAfterCallback: (() => void) | undefined; + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + const pendingRefresh = refreshAfterCallback; + refreshAfterCallback = undefined; + if (pendingRefresh) setTimeout(pendingRefresh, 750); + }); + const pbjs = installPrebidNpm(); + const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); + + pbjs.requestBids({ + adUnits: coveredCodes.map((code, index) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); + refreshAfterCallback = () => pubads.refresh(refreshSlots); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ + gamOnlySlot.getSlotElementId(), + ...coveredCodes, + ]); + expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); + + vi.advanceTimersByTime(750); + + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + + vi.runOnlyPendingTimers(); + pubads.refresh([coveredSlots[0]]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('expires a targeted delivery context before a later event-loop task', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-expiring-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), + } as any); + vi.runOnlyPendingTimers(); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('bypasses a mixed explicit delivery list spanning nested contexts', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('treats a microtask refresh without a targeting signal as an independent auction', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], From 461cff9b8ee0208b50854b09a49812ee2d98fd77 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 17 Jul 2026 11:54:55 -0500 Subject: [PATCH 128/494] Address Prebid refresh review feedback --- .../lib/src/integrations/prebid/index.ts | 359 ++++++------ .../test/integrations/prebid/index.test.ts | 543 ++++++++++++++---- 2 files changed, 604 insertions(+), 298 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 0432d8695..007a7d40d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -48,7 +48,8 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; -const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; +const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; +const MAX_PENDING_PUBLISHER_BIDS = 2048; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -245,16 +246,14 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { - remainingCodes: Set; - retainForTargetedRefresh: boolean; - cleanupTimer?: ReturnType; +type PendingPublisherBid = { + adUnitCode: string; }; -type SetTargetingForGptAsync = (...args: unknown[]) => unknown; +type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; let publisherAdUnitSnapshots = new Map(); +let pendingPublisherBids = new Map(); let syntheticRefreshAdUnits = new WeakSet(); -const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -381,26 +380,41 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { return values?.find((value) => value.length > 0); } -/** - * Find the publisher's original `pbjs.adUnits` entry for a refreshing slot. - * - * A TS-owned GPT slot may be defined on `${div_id}-container`, so the GPT - * element id used as the synthetic refresh ad unit code can differ from the - * inner `div_id` the publisher keyed their Prebid ad unit by. Try each candidate - * code in order and return the first matching ad unit, so container-backed slots - * still recover the publisher's configured params and bidders. - */ +/** Store a snapshot and evict the least-recently used entry when capacity is exceeded. */ +function storePublisherAdUnitSnapshot(code: string, snapshot: PublisherAdUnitSnapshot): void { + publisherAdUnitSnapshots.delete(code); + publisherAdUnitSnapshots.set(code, snapshot); + + if (publisherAdUnitSnapshots.size > MAX_PUBLISHER_AD_UNIT_SNAPSHOTS) { + const oldestCode = publisherAdUnitSnapshots.keys().next().value; + if (oldestCode !== undefined) publisherAdUnitSnapshots.delete(oldestCode); + } +} + +/** Find and touch a request-scoped publisher snapshot by candidate code. */ function findRefreshSnapshot( candidateCodes: Array ): PublisherAdUnitSnapshot | undefined { for (const code of candidateCodes) { if (!code) continue; const snapshot = publisherAdUnitSnapshots.get(code); - if (snapshot) return snapshot; + if (!snapshot) continue; + publisherAdUnitSnapshots.delete(code); + publisherAdUnitSnapshots.set(code, snapshot); + return snapshot; } return undefined; } +/** + * Find the publisher's live `pbjs.adUnits` entry for a refreshing slot. + * + * A TS-owned GPT slot may be defined on `${div_id}-container`, so the GPT + * element id used as the synthetic refresh ad unit code can differ from the + * inner `div_id` the publisher keyed their Prebid ad unit by. Try each candidate + * code in order and return the first matching ad unit, so container-backed slots + * still recover the publisher's configured params and bidders. + */ function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -413,6 +427,7 @@ function findRefreshAdUnit( return undefined; } +/** Deep-copy plain publisher params while preserving cycles and non-plain values. */ function copyParamValue(value: unknown, seen = new WeakMap()): unknown { if (Array.isArray(value)) { const existing = seen.get(value); @@ -449,6 +464,7 @@ function copyParams(params: Record | undefined): Record; } +/** Copy bidder params previously folded into a `trustedServer` bid. */ function foldedBidderParams( bid: TrustedServerBid | undefined ): Record> { @@ -461,6 +477,7 @@ function foldedBidderParams( ); } +/** Capture immutable request-scoped bidder and zone data before the shim mutates an ad unit. */ function capturePublisherAdUnitSnapshot( unit: TrustedServerAdUnit, clientSideBidders: Set @@ -503,34 +520,34 @@ function capturePublisherAdUnitSnapshot( * `requestBids` shim preserves a client-side bidder only when its bid entry is * already present on the ad unit, so without re-attaching them here publishers * that split demand between server-side and native Prebid adapters would lose - * all client-side demand on refresh/scroll impressions. Bids are sourced from - * the matching `pbjs.adUnits` entry (by candidate ad unit code) so the - * publisher's configured params are preserved. + * all client-side demand on refresh/scroll impressions. A live exact + * `pbjs.adUnits` match is authoritative; request-scoped snapshots are used only + * when no live unit exists. */ function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { - const snapshot = findRefreshSnapshot(candidateCodes); - if (snapshot) { - return snapshot.clientSideBids.map((bid) => ({ - bidder: bid.bidder, - params: copyParams(bid.params), - })); - } - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); - if (clientSideBidders.size === 0) return []; - const match = findRefreshAdUnit(candidateCodes); - if (!match?.bids) return []; + if (match) { + if (clientSideBidders.size === 0 || !Array.isArray(match.bids)) return []; - const bids: Array<{ bidder: string; params: Record }> = []; - for (const bid of match.bids) { - if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + const bids: Array<{ bidder: string; params: Record }> = []; + for (const bid of match.bids) { + if (bid?.bidder && clientSideBidders.has(bid.bidder)) { + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + } } + return bids; } - return bids; + + const snapshot = findRefreshSnapshot(candidateCodes); + return ( + snapshot?.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })) ?? [] + ); } /** @@ -539,49 +556,49 @@ function clientSideBidsForRefresh( * The synthetic refresh ad unit carries only the `trustedServer` bid, so the * `requestBids` shim has no original server-side bidder entries to collect into * `bidderParams` — without this, refresh/scroll `/auction` requests send `{}` - * and lose demand the publisher configured only on the initial ad unit. Source - * the params from the matching `pbjs.adUnits` entry by candidate code, covering - * both states the initial auction can leave that entry in: - * - raw server-side bidder entries (`{ bidder, params }`) not yet folded, and - * - params already folded into that unit's `trustedServer` bid `bidderParams` - * by a prior `requestBids` call. + * and lose demand the publisher configured only on the initial ad unit. A live + * exact `pbjs.adUnits` match is authoritative and covers both raw bidder entries + * and params already folded into a `trustedServer` bid. A request-scoped + * snapshot is used only when no live unit exists. */ function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { - const snapshot = findRefreshSnapshot(candidateCodes); - if (snapshot) { - return Object.fromEntries( - Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) - ); - } - const match = findRefreshAdUnit(candidateCodes); - if (!match?.bids) return {}; + if (match) { + if (!Array.isArray(match.bids)) return {}; - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); - const params: Record> = {}; + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); + const params: Record> = {}; - for (const bid of match.bids) { - if (!bid?.bidder) continue; - if (bid.bidder === ADAPTER_CODE) { - // Params captured and folded onto the trustedServer bid by an earlier - // requestBids call. - const folded = (bid.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< - string, - Record - >; - for (const [bidder, bidderParams] of Object.entries(folded)) { - params[bidder] = bidderParams; + for (const bid of match.bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + Object.assign(params, foldedBidderParams(bid)); + continue; } - continue; + if (clientSideBidders.has(bid.bidder)) continue; + params[bid.bidder] = copyParams(bid.params); } - if (clientSideBidders.has(bid.bidder)) continue; - // Raw server-side bidder entry not yet folded by the shim. - params[bid.bidder] = bid.params ?? {}; + + return params; } - return params; + const snapshot = findRefreshSnapshot(candidateCodes); + return snapshot + ? Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [ + bidder, + copyParams(params), + ]) + ) + : {}; +} + +/** Return a live publisher zone, falling back to a request-scoped snapshot. */ +function publisherZoneForRefresh(candidateCodes: Array): string | undefined { + const match = findRefreshAdUnit(candidateCodes); + return match ? match.mediaTypes?.banner?.name : findRefreshSnapshot(candidateCodes)?.zone; } function clearRefreshTargeting(slot: RefreshGptSlot): void { @@ -592,70 +609,85 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } -function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { - if (context.cleanupTimer !== undefined) { - clearTimeout(context.cleanupTimer); - context.cleanupTimer = undefined; +/** Store an auction-local bid ID for one-shot GPT delivery correlation. */ +function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid): void { + pendingPublisherBids.delete(adId); + pendingPublisherBids.set(adId, pendingBid); + + if (pendingPublisherBids.size > MAX_PENDING_PUBLISHER_BIDS) { + const oldestAdId = pendingPublisherBids.keys().next().value; + if (oldestAdId !== undefined) pendingPublisherBids.delete(oldestAdId); } - const index = activePublisherDeliveryContexts.lastIndexOf(context); - if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } -function targetingCoversPublisherDeliveryContext( - adUnitCodes: unknown, - context: PublisherDeliveryContext -): boolean { - if (adUnitCodes === undefined) return context.remainingCodes.size > 0; - const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; - return ( - Array.isArray(codes) && - codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) - ); +/** Remove every pending auction bid for an ad-unit code. */ +function removePendingPublisherBidsForCode(adUnitCode: string): void { + for (const [adId, pendingBid] of pendingPublisherBids) { + if (pendingBid.adUnitCode === adUnitCode) pendingPublisherBids.delete(adId); + } } -function consumeBarePublisherDeliveryContext(): boolean { - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - if (context.remainingCodes.size === 0) continue; - context.remainingCodes.clear(); - removePublisherDeliveryContext(context); - return true; +/** Register bid IDs from the current `bidsBackHandler` callback only. */ +function registerPendingPublisherBids(bidResponses: unknown): void { + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) return; + + for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { + if (!responseGroup || typeof responseGroup !== 'object') continue; + const bids = (responseGroup as { bids?: unknown }).bids; + if (!Array.isArray(bids)) continue; + + for (const bid of bids) { + if (!bid || typeof bid !== 'object') continue; + const response = bid as { adId?: unknown; adUnitCode?: unknown }; + const adId = typeof response.adId === 'string' ? response.adId : undefined; + const adUnitCode = + typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; + if (!adId || !adUnitCode) continue; + + storePendingPublisherBid(adId, { adUnitCode }); + } } - return false; } -function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { - if (targetSlots.length === 0) return false; +/** + * Partition slots by whether their current `hb_adid` belongs to a pending + * publisher auction, consuming every older pending bid for each matched code. + */ +function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { + const deliverySlots = new Set(); + const deliveredCodes = new Set(); - // Publishers may include GAM-only slots in the same explicit refresh that - // delivers a completed Prebid auction. Attribute the call to delivery when - // any slot is covered, while consuming only the covered codes so an - // unrelated-only refresh still follows the synthetic auction path. - const matches = new Map>(); for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const adIds = slot.getTargeting?.('hb_adid'); + if (!Array.isArray(adIds)) continue; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCode = candidates.find( - (code): code is string => !!code && context.remainingCodes.has(code) - ); - if (!coveredCode) continue; + const pendingBid = adIds + .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) + .map((adId) => pendingPublisherBids.get(adId)) + .find((bid): bid is PendingPublisherBid => bid !== undefined); + if (!pendingBid) continue; - const contextMatches = matches.get(context) ?? new Set(); - contextMatches.add(coveredCode); - matches.set(context, contextMatches); - break; - } + deliverySlots.add(slot); + deliveredCodes.add(pendingBid.adUnitCode); } - if (matches.size === 0) return false; - for (const [context, coveredCodes] of matches) { - coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); + deliveredCodes.forEach(removePendingPublisherBidsForCode); + return deliverySlots; +} + +/** Evict publisher state after Prebid removes one or more ad units. */ +function removePublisherState(adUnitCode?: string | string[]): void { + if (!adUnitCode) { + publisherAdUnitSnapshots.clear(); + pendingPublisherBids.clear(); + return; + } + + const adUnitCodes = Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]; + for (const code of adUnitCodes) { + publisherAdUnitSnapshots.delete(code); + removePendingPublisherBidsForCode(code); } - return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -695,8 +727,18 @@ function collectAuctionEids(): AuctionEid[] | undefined { */ export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); + pendingPublisherBids = new Map(); syntheticRefreshAdUnits = new WeakSet(); - [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); + + const prebidWithRemoveAdUnit = pbjs as unknown as { removeAdUnit?: RemoveAdUnit }; + const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; + if (typeof originalRemoveAdUnit === 'function') { + prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { + const result = originalRemoveAdUnit.call(this, adUnitCode); + removePublisherState(adUnitCode); + return result; + }; + } const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -772,15 +814,19 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; const isSyntheticRefresh = adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); - const publisherAdUnitCodes = new Set(); + const publisherAdUnitCodes = new Set( + adUnits + .filter((unit) => !syntheticRefreshAdUnits.has(unit)) + .map((unit) => unit.code) + .filter((code): code is string => typeof code === 'string' && code.length > 0) + ); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { if (!syntheticRefreshAdUnits.has(unit)) { const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); if (snapshot && unit.code) { - publisherAdUnitSnapshots.set(unit.code, snapshot); - publisherAdUnitCodes.add(unit.code); + storePublisherAdUnitSnapshot(unit.code, snapshot); } } @@ -868,51 +914,11 @@ export function installPrebidNpm(config?: Partial): typeof pbjs opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); if (typeof originalBidsBack !== 'function') return; - if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { - originalBidsBack.apply(this, args as Parameters); - return; - } - - const context: PublisherDeliveryContext = { - remainingCodes: new Set(publisherAdUnitCodes), - retainForTargetedRefresh: false, - }; - const targetingPbjs = pbjs as unknown as { - setTargetingForGPTAsync?: SetTargetingForGptAsync; - }; - const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; - let targetingWrapper: SetTargetingForGptAsync | undefined; - if (typeof originalSetTargeting === 'function') { - targetingWrapper = (...targetingArgs: unknown[]) => { - const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); - if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { - context.retainForTargetedRefresh = true; - } - return result; - }; - targetingPbjs.setTargetingForGPTAsync = targetingWrapper; - } - - activePublisherDeliveryContexts.push(context); - try { - originalBidsBack.apply(this, args as Parameters); - } finally { - if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { - targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; - } - if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { - // Some publisher wrappers set targeting in bidsBackHandler, return, - // and schedule the matching GPT refresh shortly afterward. Retain - // this one-shot context only after that targeting signal, with a - // bounded expiry so a later independent refresh remains independent. - context.cleanupTimer = setTimeout( - () => removePublisherDeliveryContext(context), - PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS - ); - } else { - removePublisherDeliveryContext(context); - } + if (!isSyntheticRefresh) { + publisherAdUnitCodes.forEach(removePendingPublisherBidsForCode); + registerPendingPublisherBids(args[0]); } + originalBidsBack.apply(this, args as Parameters); }; return originalRequestBids(opts); @@ -1012,33 +1018,30 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - const isExplicitSlotList = slots !== undefined; - const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; - const isPublisherDeliveryRefresh = isExplicitSlotList - ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) - : consumeBarePublisherDeliveryContext(); - if (isPublisherDeliveryRefresh) { + if (!targetSlots.length) { return originalRefresh(slots, opts); } - if (!targetSlots.length) { - return originalRefresh(slots, opts); + const deliverySlots = publisherDeliverySlots(targetSlots); + const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); + if (deliverySlots.size > 0) { + originalRefresh([...deliverySlots], opts); } + if (independentSlots.length === 0) return; - targetSlots.forEach(clearRefreshTargeting); + independentSlots.forEach(clearRefreshTargeting); - const adUnits = targetSlots.map((slot) => { + const adUnits = independentSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); const code = refreshSlotElementId(slot) ?? 'refresh-slot'; // A TS-owned slot may be defined on `${div_id}-container`, so the GPT // element id used as the synthetic refresh code can differ from the // inner `div_id` the publisher keyed their ad unit by. Recover from both. const candidateCodes = [code, injectedSlot?.div_id]; - const snapshot = findRefreshSnapshot(candidateCodes); const zone = injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? - snapshot?.zone; + publisherZoneForRefresh(candidateCodes); const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -1073,7 +1076,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { adUnits, bidsBackHandler: () => { pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); - originalRefresh(targetSlots, opts); + originalRefresh(independentSlots, opts); }, timeout: timeoutMs, }); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 4c88a02c2..92d2b07d9 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -8,6 +8,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockRemoveAdUnit, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -21,14 +22,34 @@ const { () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); const mockGetConfig = vi.fn(); - const mockPbjs = { + let mockPbjs: { + setConfig: typeof mockSetConfig; + processQueue: typeof mockProcessQueue; + requestBids: typeof mockRequestBids; + registerBidAdapter: typeof mockRegisterBidAdapter; + getUserIdsAsEids: typeof mockGetUserIdsAsEids; + getConfig: typeof mockGetConfig; + removeAdUnit: ReturnType; + adUnits: any[]; + [key: string]: any; + }; + const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { + if (!adUnitCode) { + mockPbjs.adUnits = []; + return; + } + const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); + mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); + }); + mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, - adUnits: [] as any[], + removeAdUnit: mockRemoveAdUnit, + adUnits: [], }; const mockAdapterManager = { getBidAdapter: mockGetBidAdapter, @@ -40,6 +61,7 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockRemoveAdUnit, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -1413,10 +1435,18 @@ describe('prebid/installRefreshHandler', () => { }); describe('prebid publisher snapshots and delivery refreshes', () => { + let deliveryAdIds = new WeakMap(); + let installedGptSlots: any[] = []; + let auctionSequence = 0; + beforeEach(() => { vi.clearAllMocks(); + deliveryAdIds = new WeakMap(); + installedGptSlots = []; + auctionSequence = 0; mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; + mockPbjs.removeAdUnit = mockRemoveAdUnit; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -1434,6 +1464,17 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); function installGpt(slots: any[]) { + installedGptSlots = slots; + for (const slot of slots) { + if (!slot || typeof slot !== 'object') continue; + const originalGetTargeting = slot.getTargeting?.bind(slot); + slot.getTargeting = (key: string) => { + const deliveryAdId = deliveryAdIds.get(slot); + if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; + return originalGetTargeting?.(key) ?? []; + }; + } + const originalRefresh = vi.fn(); const pubads = { refresh: originalRefresh, @@ -1452,6 +1493,31 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return lastCall?.[0]?.adUnits?.[0]; } + function completePublisherAuction( + opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: any[]) => void }, + options: { auctionId?: string; applyTargeting?: boolean } = {} + ): void { + const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; + const bidResponses: Record = {}; + + for (const unit of opts?.adUnits ?? []) { + if (!unit.code) continue; + const adId = `${auctionId}-${unit.code}`; + bidResponses[unit.code] = { + bids: [{ adId, adUnitCode: unit.code, auctionId }], + }; + if (options.applyTargeting !== false) { + const slot = installedGptSlots.find((candidate) => { + const elementId = candidate?.getSlotElementId?.(); + return elementId === unit.code || elementId === `${unit.code}-container`; + }); + if (slot) deliveryAdIds.set(slot, adId); + } + } + + opts?.bidsBackHandler?.(bidResponses, false, auctionId); + } + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; const runtimeInstance = 'example-runtime-instance'; @@ -1677,6 +1743,143 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); }); + it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-live-rich-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const liveUnit = { + code, + bids: [ + { bidder: 'exampleServer', params: { placement: 'live-server' } }, + { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, + ], + }; + mockPbjs.adUnits = [liveUnit]; + const pbjs = installPrebidNpm(); + + pbjs.requestBids(); + pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual([ + { + bidder: 'trustedServer', + params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, + }, + { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, + ]); + }); + + it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { + const code = 'example-live-empty-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], + } as any); + mockPbjs.adUnits = [{ code, bids: [] }]; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual([ + { bidder: 'trustedServer', params: { bidderParams: {} } }, + ]); + }); + + it('evicts snapshots with the matching removeAdUnit lifecycle', () => { + const codes = ['example-remove-one', 'example-remove-two', 'example-remove-all']; + const slots = codes.map((code) => ({ + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const { pubads } = installGpt(slots); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: codes.map((code) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: code } }], + })), + } as any); + (pbjs as any).removeAdUnit(codes[0]); + (pbjs as any).removeAdUnit([codes[1]]); + + pubads.refresh([slots[0]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[1]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[2]]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: codes[2] }, + }); + + (pbjs as any).removeAdUnit(); + pubads.refresh([slots[2]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + }); + + it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { + const capacity = 256; + const oldestCode = 'example-lru-0'; + const activeCode = `example-lru-${capacity - 1}`; + const oldestSlot = { + getSlotElementId: () => oldestCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const activeSlot = { + getSlotElementId: () => activeCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([oldestSlot, activeSlot]); + const pbjs = installPrebidNpm(); + + for (let index = 0; index < capacity; index += 1) { + pbjs.requestBids({ + adUnits: [ + { + code: `example-lru-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + }, + ], + } as any); + } + + pubads.refresh([activeSlot]); + pbjs.requestBids({ + adUnits: [ + { + code: `example-lru-${capacity}`, + bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], + }, + ], + } as any); + + pubads.refresh([oldestSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([activeSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: capacity - 1 }, + }); + }); + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { const slotOne = { getSlotElementId: () => 'example-covered-one', @@ -1692,9 +1895,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], }; const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1716,7 +1917,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); }); - it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + it('partitions a bare delivery refresh from an unmatched GPT slot', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1728,9 +1929,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1738,14 +1937,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh(), } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [coveredSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); - it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { + it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1759,9 +1959,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1772,19 +1970,23 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(mockRequestBids).toHaveBeenCalledTimes(3); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledTimes(3); expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(3, [unrelatedSlot], undefined); }); - it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + it('partitions four delivered slots from an unmatched explicit slot', () => { const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ getSlotElementId: () => `example-covered-${index}`, getTargeting: () => [], @@ -1797,9 +1999,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const refreshSlots = [...coveredSlots, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1810,111 +2010,125 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh(refreshSlots), } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, coveredSlots, undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); - it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + it('correlates a targeted delivery refresh after more than one second without a timer race', () => { vi.useFakeTimers(); try { - const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ - getSlotElementId: () => `example-targeted-${index}`, + const code = 'example-delayed-delivery'; + const auctionId = 'example-delayed-auction'; + const slot = { + getSlotElementId: () => code, getTargeting: () => [], getSizes: () => [[300, 250]], clearTargeting: vi.fn(), - })); - const gamOnlySlot = { - getSlotElementId: () => 'example-targeted-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), }; - const refreshSlots = [...coveredSlots, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - const setTargetingForGPTAsync = vi.fn(); - (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; - let refreshAfterCallback: (() => void) | undefined; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - const pendingRefresh = refreshAfterCallback; - refreshAfterCallback = undefined; - if (pendingRefresh) setTimeout(pendingRefresh, 750); - }); + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); const pbjs = installPrebidNpm(); - const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); pbjs.requestBids({ - adUnits: coveredCodes.map((code, index) => ({ - code, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - })), + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { - (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); - refreshAfterCallback = () => pubads.refresh(refreshSlots); + setTimeout(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + pubads.refresh([slot]); + }, 1500); }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ - gamOnlySlot.getSlotElementId(), - ...coveredCodes, - ]); - expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); - - vi.advanceTimersByTime(750); + vi.advanceTimersByTime(1500); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - - vi.runOnlyPendingTimers(); - pubads.refresh([coveredSlots[0]]); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(originalRefresh).toHaveBeenCalledTimes(2); } finally { vi.runOnlyPendingTimers(); vi.useRealTimers(); - delete (mockPbjs as any).setTargetingForGPTAsync; } }); - it('expires a targeted delivery context before a later event-loop task', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-expiring-delivery', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const pbjs = installPrebidNpm(); + it('correlates null and no-argument targeting with a custom GPT slot match', () => { + const code = 'example-custom-matched-code'; + const slot = { + getSlotElementId: () => 'example-different-gpt-slot', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let auctionId = 'example-null-auction'; + const setTargetingForGPTAsync = vi.fn(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + }); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [ - { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), - } as any); - vi.runOnlyPendingTimers(); - pubads.refresh([slot]); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync(null, () => () => true); + pubads.refresh([slot]); + }, + } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - delete (mockPbjs as any).setTargetingForGPTAsync; - } + auctionId = 'example-no-argument-auction'; + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync(); + pubads.refresh([slot]); + }, + } as any); + + expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); + expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + delete (mockPbjs as any).setTargetingForGPTAsync; + }); + + it('uses the synthetic path when callback bid responses are missing or malformed', () => { + const slot = { + getSlotElementId: () => 'example-no-bid-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: (...args: any[]) => void }) => { + opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([slot]), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); it('bypasses a mixed explicit delivery list spanning nested contexts', () => { @@ -1935,9 +2149,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1954,10 +2166,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot, outerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); it('treats a microtask refresh without a targeting signal as an independent auction', async () => { @@ -1968,9 +2183,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); const pbjs = installPrebidNpm(); let deferredRefresh: Promise | undefined; @@ -1990,6 +2205,98 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); + it('correlates targeting and refresh deferred together to a microtask', async () => { + const code = 'example-targeted-microtask'; + const auctionId = 'example-targeted-microtask-auction'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + pubads.refresh([slot]); + }); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('consumes all overlapping pending bids for the same ad-unit code', () => { + const code = 'example-overlapping-code'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as any); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as any); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + + deliveryAdIds.set(slot, `example-auction-0-${code}`); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + }); + + it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { + const code = 'example-valid-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot, undefined, null] as any), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + }); + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { const outerSlot = { getSlotElementId: () => 'example-outer-delivery', @@ -2002,9 +2309,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -2037,9 +2342,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); const pbjs = installPrebidNpm(); expect(() => @@ -2071,9 +2376,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); installPrebidNpm(); pubads.refresh([slot]); From e45b6b5a16e7274ac778fc9e6a0fa0f7f3c83d75 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 24 Jul 2026 12:40:05 -0500 Subject: [PATCH 129/494] Resolve Prebid refresh review feedback --- .../lib/src/integrations/prebid/index.ts | 213 +++++++++++--- .../lib/test/integrations/gpt/ad_init.test.ts | 1 + .../test/integrations/prebid/index.test.ts | 267 ++++++++++++++++-- 3 files changed, 417 insertions(+), 64 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 007a7d40d..9e0632e26 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -50,6 +50,7 @@ const TS_REFRESH_TARGETING_KEYS = [ ] as const; const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; const MAX_PENDING_PUBLISHER_BIDS = 2048; +const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -248,11 +249,23 @@ type PublisherAdUnitSnapshot = { }; type PendingPublisherBid = { adUnitCode: string; + expiresAt: number; + registrationId: number; +}; +type PendingPublisherCode = { + expiresAt: number; + registrationId: number; }; type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; +type PrebidWithRemoveAdUnit = { + removeAdUnit?: RemoveAdUnit; + __tsRemoveAdUnitWrapped?: boolean; +}; let publisherAdUnitSnapshots = new Map(); let pendingPublisherBids = new Map(); +let pendingPublisherCodes = new Map(); +let pendingPublisherRegistrationId = 0; let syntheticRefreshAdUnits = new WeakSet(); type TrustedServerBidRequest = { adUnitCode?: string; @@ -609,7 +622,45 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } -/** Store an auction-local bid ID for one-shot GPT delivery correlation. */ +/** Remove pending delivery state for an ad unit, optionally from one registration only. */ +function removePendingPublisherBidsForCode(adUnitCode: string, registrationId?: number): void { + const pendingCode = pendingPublisherCodes.get(adUnitCode); + if (registrationId !== undefined && pendingCode?.registrationId !== registrationId) return; + + pendingPublisherCodes.delete(adUnitCode); + for (const [adId, pendingBid] of pendingPublisherBids) { + if ( + pendingBid.adUnitCode === adUnitCode && + (registrationId === undefined || pendingBid.registrationId === registrationId) + ) { + pendingPublisherBids.delete(adId); + } + } +} + +/** Discard delivery state that outlived the publisher auction which created it. */ +function prunePendingPublisherBids(now = Date.now()): void { + for (const [adUnitCode, pendingCode] of pendingPublisherCodes) { + if (pendingCode.expiresAt <= now) removePendingPublisherBidsForCode(adUnitCode); + } + + for (const [adId, pendingBid] of pendingPublisherBids) { + if (pendingBid.expiresAt <= now) pendingPublisherBids.delete(adId); + } +} + +/** Store a short-lived pending publisher ad-unit code for delivery correlation. */ +function storePendingPublisherCode(adUnitCode: string, pendingCode: PendingPublisherCode): void { + pendingPublisherCodes.delete(adUnitCode); + pendingPublisherCodes.set(adUnitCode, pendingCode); + + if (pendingPublisherCodes.size > MAX_PENDING_PUBLISHER_BIDS) { + const oldestCode = pendingPublisherCodes.keys().next().value; + if (oldestCode !== undefined) removePendingPublisherBidsForCode(oldestCode); + } +} + +/** Store an auction-local bid ID for precise one-shot GPT delivery correlation. */ function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid): void { pendingPublisherBids.delete(adId); pendingPublisherBids.set(adId, pendingBid); @@ -620,16 +671,23 @@ function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid) } } -/** Remove every pending auction bid for an ad-unit code. */ -function removePendingPublisherBidsForCode(adUnitCode: string): void { - for (const [adId, pendingBid] of pendingPublisherBids) { - if (pendingBid.adUnitCode === adUnitCode) pendingPublisherBids.delete(adId); +/** Register every requested publisher code and any bid IDs returned for that auction. */ +function registerPendingPublisherBids( + publisherAdUnitCodes: Set, + bidResponses: unknown +): number { + prunePendingPublisherBids(); + const registrationId = ++pendingPublisherRegistrationId; + const expiresAt = Date.now() + PENDING_PUBLISHER_DELIVERY_TTL_MS; + + for (const adUnitCode of publisherAdUnitCodes) { + removePendingPublisherBidsForCode(adUnitCode); + storePendingPublisherCode(adUnitCode, { expiresAt, registrationId }); } -} -/** Register bid IDs from the current `bidsBackHandler` callback only. */ -function registerPendingPublisherBids(bidResponses: unknown): void { - if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) return; + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) { + return registrationId; + } for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { if (!responseGroup || typeof responseGroup !== 'object') continue; @@ -642,36 +700,53 @@ function registerPendingPublisherBids(bidResponses: unknown): void { const adId = typeof response.adId === 'string' ? response.adId : undefined; const adUnitCode = typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; - if (!adId || !adUnitCode) continue; + if (!adId || !adUnitCode || !publisherAdUnitCodes.has(adUnitCode)) continue; - storePendingPublisherBid(adId, { adUnitCode }); + storePendingPublisherBid(adId, { adUnitCode, expiresAt, registrationId }); } } + + return registrationId; } /** - * Partition slots by whether their current `hb_adid` belongs to a pending - * publisher auction, consuming every older pending bid for each matched code. + * Partition slots by whether they belong to a pending publisher auction. + * + * A current `hb_adid` is the precise signal. When publishers intentionally + * omit that targeting, a short-lived requested-code match preserves delivery + * for no-bid and custom-targeting auctions. A non-empty unmatched ID remains + * independent so stale targeting cannot suppress a fresh auction. Every match + * is consumed once. */ function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { + prunePendingPublisherBids(); const deliverySlots = new Set(); const deliveredCodes = new Set(); for (const slot of targetSlots) { const adIds = slot.getTargeting?.('hb_adid'); - if (!Array.isArray(adIds)) continue; - - const pendingBid = adIds - .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) - .map((adId) => pendingPublisherBids.get(adId)) - .find((bid): bid is PendingPublisherBid => bid !== undefined); - if (!pendingBid) continue; + const pendingBid = Array.isArray(adIds) + ? adIds + .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) + .map((adId) => pendingPublisherBids.get(adId)) + .find((bid): bid is PendingPublisherBid => bid !== undefined) + : undefined; + const hasAdId = + Array.isArray(adIds) && adIds.some((adId) => typeof adId === 'string' && adId.length > 0); + const injectedSlot = findInjectedSlotForRefresh(slot); + const pendingCode = hasAdId + ? undefined + : [refreshSlotElementId(slot), injectedSlot?.div_id] + .filter((code): code is string => typeof code === 'string' && code.length > 0) + .find((code) => pendingPublisherCodes.has(code)); + const adUnitCode = pendingBid?.adUnitCode ?? pendingCode; + if (!adUnitCode) continue; deliverySlots.add(slot); - deliveredCodes.add(pendingBid.adUnitCode); + deliveredCodes.add(adUnitCode); } - deliveredCodes.forEach(removePendingPublisherBidsForCode); + deliveredCodes.forEach((adUnitCode) => removePendingPublisherBidsForCode(adUnitCode)); return deliverySlots; } @@ -680,6 +755,7 @@ function removePublisherState(adUnitCode?: string | string[]): void { if (!adUnitCode) { publisherAdUnitSnapshots.clear(); pendingPublisherBids.clear(); + pendingPublisherCodes.clear(); return; } @@ -728,16 +804,21 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); pendingPublisherBids = new Map(); + pendingPublisherCodes = new Map(); + pendingPublisherRegistrationId = 0; syntheticRefreshAdUnits = new WeakSet(); - const prebidWithRemoveAdUnit = pbjs as unknown as { removeAdUnit?: RemoveAdUnit }; - const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; - if (typeof originalRemoveAdUnit === 'function') { - prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { - const result = originalRemoveAdUnit.call(this, adUnitCode); - removePublisherState(adUnitCode); - return result; - }; + const prebidWithRemoveAdUnit = pbjs as unknown as PrebidWithRemoveAdUnit; + if (!prebidWithRemoveAdUnit.__tsRemoveAdUnitWrapped) { + const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; + if (typeof originalRemoveAdUnit === 'function') { + prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { + const result = originalRemoveAdUnit.call(this, adUnitCode); + removePublisherState(adUnitCode); + return result; + }; + prebidWithRemoveAdUnit.__tsRemoveAdUnitWrapped = true; + } } const injected = getInjectedConfig(); @@ -809,7 +890,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs log.debug('[tsjs-prebid] requestBids called'); recordUserIdModuleDiagnostics(); - const opts = requestObj || {}; + const opts = { ...(requestObj ?? {}) }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; const isSyntheticRefresh = @@ -913,12 +994,21 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); + const registrationId = isSyntheticRefresh + ? undefined + : registerPendingPublisherBids(publisherAdUnitCodes, args[0]); if (typeof originalBidsBack !== 'function') return; - if (!isSyntheticRefresh) { - publisherAdUnitCodes.forEach(removePendingPublisherBidsForCode); - registerPendingPublisherBids(args[0]); + + try { + originalBidsBack.apply(this, args as Parameters); + } catch (error) { + if (registrationId !== undefined) { + publisherAdUnitCodes.forEach((code) => + removePendingPublisherBidsForCode(code, registrationId) + ); + } + throw error; } - originalBidsBack.apply(this, args as Parameters); }; return originalRequestBids(opts); @@ -1018,16 +1108,15 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - if (!targetSlots.length) { + if (!targetSlots.length || (slots !== undefined && targetSlots.length !== slots.length)) { return originalRefresh(slots, opts); } const deliverySlots = publisherDeliverySlots(targetSlots); const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); - if (deliverySlots.size > 0) { - originalRefresh([...deliverySlots], opts); + if (independentSlots.length === 0) { + return originalRefresh(slots, opts); } - if (independentSlots.length === 0) return; independentSlots.forEach(clearRefreshTargeting); @@ -1072,14 +1161,44 @@ export function installRefreshHandler(timeoutMs = 1500): void { // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); - pbjs.requestBids({ - adUnits, - bidsBackHandler: () => { - pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); - originalRefresh(independentSlots, opts); - }, - timeout: timeoutMs, - }); + + // Preserve GPT Single Request Architecture: when a publisher refresh + // includes both already-targeted delivery slots and independent slots, + // delay the whole original list until the independent auction completes. + // A one-shot fallback prevents a failed Prebid callback from dropping any + // slots, and a late callback cannot issue a second GAM request. + let completed = false; + let fallbackTimer: ReturnType | undefined; + function completeRefresh(): void { + if (completed) return; + completed = true; + if (fallbackTimer !== undefined) clearTimeout(fallbackTimer); + originalRefresh(slots, opts); + } + + try { + pbjs.requestBids({ + adUnits, + bidsBackHandler: () => { + if (completed) return; + try { + pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); + } catch (error) { + log.error('[tsjs-prebid] refresh targeting failed', error); + } finally { + completeRefresh(); + } + }, + timeout: timeoutMs, + }); + // Prebid schedules its own timeout during requestBids(). Schedule this + // fallback afterward so its normal timeout callback gets first chance + // to apply targeting before the one-shot GPT completion path runs. + if (!completed) fallbackTimer = setTimeout(completeRefresh, timeoutMs); + } catch (error) { + log.error('[tsjs-prebid] refresh auction failed', error); + completeRefresh(); + } }; log.info('[tsjs-prebid] GPT refresh handler installed'); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index ac55b60de..733c43642 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -133,6 +133,7 @@ describe('installTsAdInit', () => { expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.enableSingleRequest).toHaveBeenCalledOnce(); expect(mockPubads.refresh).toHaveBeenCalled(); fetchSpy.mockRestore(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 92d2b07d9..ab253f758 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1447,6 +1447,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; mockPbjs.removeAdUnit = mockRemoveAdUnit; + delete (mockPbjs as any).__tsRemoveAdUnitWrapped; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -1917,6 +1918,64 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); }); + it('registers delivery state for a publisher auction without a bidsBackHandler', () => { + const code = 'example-handlerless-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('preserves one mixed refresh request and its original options', () => { + const deliverySlot = { + getSlotElementId: () => 'example-sra-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const independentSlot = { + getSlotElementId: () => 'example-sra-independent', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshOptions = { changeCorrelator: true }; + const { originalRefresh, pubads } = installGpt([deliverySlot, independentSlot]); + let syntheticBidsBackHandler: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + if (mockRequestBids.mock.calls.length === 1) { + completePublisherAuction(opts); + } else { + syntheticBidsBackHandler = opts.bidsBackHandler; + } + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-sra-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([deliverySlot, independentSlot], refreshOptions), + } as any); + + expect(originalRefresh).not.toHaveBeenCalled(); + expect(independentSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + + syntheticBidsBackHandler?.(); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([deliverySlot, independentSlot], refreshOptions); + }); + it('partitions a bare delivery refresh from an unmatched GPT slot', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', @@ -1940,9 +1999,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [coveredSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { @@ -1980,10 +2038,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(3); + expect(originalRefresh).toHaveBeenCalledTimes(2); expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(3, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); it('partitions four delivered slots from an unmatched explicit slot', () => { @@ -2013,9 +2070,39 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(2); coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, coveredSlots, undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('expires an unconsumed publisher delivery before a later refresh', () => { + vi.useFakeTimers(); + try { + const code = 'example-expired-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + } as any); + vi.advanceTimersByTime(5001); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } }); it('correlates a targeted delivery refresh after more than one second without a timer race', () => { @@ -2106,7 +2193,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { delete (mockPbjs as any).setTargetingForGPTAsync; }); - it('uses the synthetic path when callback bid responses are missing or malformed', () => { + it('correlates requested no-bid slots without manufacturing unrelated bid state', () => { const slot = { getSlotElementId: () => 'example-no-bid-delivery', getTargeting: () => [], @@ -2126,6 +2213,30 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh([slot]), } as any); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('does not use code fallback when a slot has an unmatched hb_adid', () => { + const code = 'example-stale-targeting'; + const slot = { + getSlotElementId: () => code, + getTargeting: (key: string) => (key === 'hb_adid' ? ['example-stale-ad-id'] : []), + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot]), + } as any); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); @@ -2170,12 +2281,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot, outerSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); }); - it('treats a microtask refresh without a targeting signal as an independent auction', async () => { + it('correlates a microtask refresh by its requested code without targeting', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], @@ -2199,8 +2309,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as any); await deferredRefresh; - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); @@ -2290,11 +2400,134 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(1); expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + expect(originalRefresh).toHaveBeenCalledWith([slot, undefined, null], undefined); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + }); + + it('does not mutate reused publisher request options', () => { + const code = 'example-reused-request'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + const request = { + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + }; + pbjs.requestBids(request as any); + pbjs.requestBids(request as any); pubads.refresh([slot]); + + expect(request).not.toHaveProperty('bidsBackHandler'); expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('falls back to one GPT refresh when a synthetic auction throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-refresh', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation(() => { + throw new Error('example synthetic failure'); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('falls back once when a synthetic auction never calls back', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-missing-refresh-callback', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation(() => undefined); + installPrebidNpm(); + + pubads.refresh([slot]); + expect(originalRefresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(640); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + + it('ignores a synthetic callback that arrives after the fallback refresh', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-late-refresh-callback', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let syntheticBidsBackHandler: (() => void) | undefined; + mockRequestBids.mockImplementation((opts) => { + syntheticBidsBackHandler = opts.bidsBackHandler; + }); + installPrebidNpm(); + + pubads.refresh([slot]); + vi.advanceTimersByTime(640); + syntheticBidsBackHandler?.(); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + + it('completes a synthetic refresh when targeting throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-targeting', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(() => { + throw new Error('example targeting failure'); + }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('does not stack the removeAdUnit lifecycle wrapper across installation', () => { + const pbjs = installPrebidNpm(); + installPrebidNpm(); + + (pbjs as any).removeAdUnit('example-reinstalled-slot'); + + expect(mockRemoveAdUnit).toHaveBeenCalledTimes(1); }); it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { From af46b98dfb432c80d0c9e2b4d73207fe307c03ca Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 12:04:27 -0500 Subject: [PATCH 130/494] Make auction creative rewriting optional Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior. --- CHANGELOG.md | 1 + .../src/auction/endpoints.rs | 7 +- .../src/auction/formats.rs | 141 +++++++++++++++++- .../src/auction/orchestrator.rs | 1 + .../src/auction_config_types.rs | 22 +++ .../trusted-server-core/src/config_payload.rs | 24 +++ crates/trusted-server-core/src/proxy.rs | 45 ++++++ crates/trusted-server-core/src/settings.rs | 35 +++++ docs/guide/auction-orchestration.md | 69 ++++++--- docs/guide/configuration.md | 26 +++- docs/guide/creative-processing.md | 54 +++++-- trusted-server.example.toml | 4 + 12 files changed, 382 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc49c80b..fddc8009d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory `/auction` creative sanitization while skipping first-party resource/click URL rewriting and creative TSJS injection. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 1b0ced7a7..e5796323f 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -76,9 +76,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// ## Response /// /// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's -/// `adm` field after sanitisation and first-party URL rewriting. Response -/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and -/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). +/// `adm` field after mandatory server-side sanitization. First-party resource +/// and click URL rewriting plus creative TSJS injection are enabled by default; +/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`] +/// to `false` skips only that rewrite pass. /// /// ## Scroll, refresh, and SPA navigation /// diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a18..71f9a290c 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -217,7 +217,8 @@ pub fn convert_tsjs_to_auction_request( /// Convert `OrchestrationResult` to `OpenRTB` response format. /// -/// Returns rewritten creative HTML directly in the `adm` field for inline delivery. +/// Always sanitizes creative HTML in the `adm` field and optionally rewrites it +/// according to the auction configuration. /// /// # Errors /// @@ -250,21 +251,34 @@ pub fn convert_to_openrtb_response( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Process creative HTML if present - — sanitize dangerous markup first, then rewrite URLs. + // Process creative HTML if present — always sanitize dangerous markup first. let creative_html = if let Some(ref raw_creative) = bid.creative { let sanitized = creative::sanitize_creative_html(raw_creative); - let rewritten = creative::rewrite_creative_html(settings, &sanitized); + let sanitized_len = sanitized.len(); + let rewrite_creatives = settings.auction.rewrite_creatives; + let processed = if rewrite_creatives { + creative::rewrite_creative_html(settings, &sanitized) + } else { + sanitized + }; + let rewrite_mode = if rewrite_creatives { + "enabled" + } else { + "disabled" + }; log::debug!( - "Processed creative for auction {} slot {} ({} → {} → {} bytes)", + "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, + bid.bidder, + rewrite_mode, raw_creative.len(), - sanitized.len(), - rewritten.len() + sanitized_len, + processed.len() ); - rewritten + processed } else { // No creative provided (e.g., from mediation layer that returns iframe URLs) log::warn!( @@ -445,6 +459,15 @@ mod tests { } } + fn make_complete_creative_bid() -> Bid { + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some( + r#""# + .to_string(), + ); + bid + } + fn make_result(bid: Bid) -> OrchestrationResult { OrchestrationResult { provider_responses: vec![AuctionResponse { @@ -466,6 +489,13 @@ mod tests { .expect("should parse JSON response") } + fn response_adm(response: Response) -> String { + response_json(response)["seatbid"][0]["bid"][0]["adm"] + .as_str() + .expect("should serialize adm as a string") + .to_string() + } + fn make_banner_body(config: Option) -> AdRequest { AdRequest { ad_units: vec![AdUnit { @@ -932,6 +962,103 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting enabled"); + let adm = response_adm(response); + + assert!( + adm.matches("/first-party/proxy?tsurl=").count() >= 2, + "should rewrite image and inline CSS URLs through the proxy: {adm}" + ); + assert!( + adm.contains("/first-party/click?tsurl="), + "should rewrite click URLs: {adm}" + ); + assert!( + adm.contains("data-tsclick"), + "should add the click guard attribute: {adm}" + ); + assert!( + adm.contains("tsjs-unified.min.js"), + "should inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should not retain the image URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should not retain the click URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains("url(https://styles.example.com/bg.png)"), + "should not retain the CSS URL as a direct value: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should remove malicious script content before rewriting: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should remove event handlers before rewriting: {adm}" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting disabled"); + let adm = response_adm(response); + + assert!( + adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should retain the sanitizer-accepted image URL: {adm}" + ); + assert!( + adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should retain the sanitizer-accepted click URL: {adm}" + ); + assert!( + adm.contains("url(https://styles.example.com/bg.png)"), + "should retain the sanitizer-accepted CSS URL: {adm}" + ); + assert!( + !adm.contains("/first-party/proxy"), + "should not rewrite resource URLs: {adm}" + ); + assert!( + !adm.contains("/first-party/click"), + "should not rewrite click URLs: {adm}" + ); + assert!( + !adm.contains("data-tsclick"), + "should not add the click guard attribute: {adm}" + ); + assert!( + !adm.contains("tsjs-unified.min.js"), + "should not inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should still remove malicious script content: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should still remove event handlers: {adm}" + ); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bf9ecad7b..69455e894 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2102,6 +2102,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + rewrite_creatives: true, providers: vec![], mediator: None, timeout_ms: 2000, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 3bd747f64..f1d1a5cf0 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,10 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. + #[serde(default = "default_rewrite_creatives")] + pub rewrite_creatives: bool, + /// Provider names that participate in bidding /// Simply list the provider names (e.g., ["prebid", "aps"]) #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] @@ -41,6 +45,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, timeout_ms: default_timeout(), @@ -54,6 +59,10 @@ fn default_timeout() -> u32 { 2000 } +fn default_rewrite_creatives() -> bool { + true +} + fn default_creative_store() -> String { "creative_store".to_owned() } @@ -79,3 +88,16 @@ impl AuctionConfig { self.mediator.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_creatives_defaults_to_true() { + assert!( + AuctionConfig::default().rewrite_creatives, + "should enable creative rewriting by default" + ); + } +} diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index dd0b35337..58c185381 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -78,6 +78,30 @@ mod tests { ); } + #[test] + fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { + let mut data = + serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); + let auction = data + .get_mut("auction") + .and_then(serde_json::Value::as_object_mut) + .expect("should serialize auction settings as an object"); + assert!( + auction.remove("rewrite_creatives").is_some(), + "should remove the newly serialized setting" + ); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + + let reconstructed = + settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); + + assert!( + reconstructed.auction.rewrite_creatives, + "should enable creative rewriting for legacy blobs" + ); + } + #[test] fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 43cff69bc..d5268388c 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -2884,6 +2884,51 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); } + #[test] + fn auction_rewrite_setting_does_not_change_proxied_html_or_css_rewriting() { + let mut settings = create_test_settings(); + settings.auction.rewrite_creatives = false; + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + + let html = r#""#; + let mut html_response = build_http_response(StatusCode::OK, EdgeBody::from(html)); + html_response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + let html_output = finalize( + &settings, + &req, + "https://cdn.example/creative.html", + html_response, + ) + .expect("should finalize proxied HTML"); + let html_body = response_body_string(html_output); + + let css = "body{background:url(https://cdn.example/bg.png)}"; + let mut css_response = build_http_response(StatusCode::OK, EdgeBody::from(css)); + css_response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css")); + let css_output = finalize( + &settings, + &req, + "https://cdn.example/creative.css", + css_response, + ) + .expect("should finalize proxied CSS"); + let css_body = response_body_string(css_output); + + assert!( + html_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied HTML when auction rewriting is disabled: {html_body}" + ); + assert!( + css_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied CSS when auction rewriting is disabled: {css_body}" + ); + } + #[test] fn html_response_rewrite_preserves_non_standard_port() { // Verify that HTML rewriting preserves non-standard ports in sub-resource URLs. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9d4d352e6..be0216008 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4384,6 +4384,41 @@ origin_host_header_overide = "www.example.com""#, assert!(!rewrite.is_excluded("")); } + #[test] + fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + settings.auction.rewrite_creatives, + "should preserve creative rewriting when the setting is omitted" + ); + } + + #[test] + fn test_auction_rewrite_creatives_accepts_explicit_false() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + rewrite_creatives = false + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + !settings.auction.rewrite_creatives, + "should disable creative rewriting when explicitly configured" + ); + } + #[test] fn test_auction_allowed_context_keys_defaults_to_empty() { let settings = create_test_settings(); diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d75958812..c6c82dac3 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -12,7 +12,7 @@ Key capabilities: - **Strategy-based winner selection** — Automatic strategy detection based on configuration - **Mediator support** — Optional external mediator for decoding encoded prices (e.g., APS) and applying unified floor pricing - **Provider abstraction** — Pluggable provider interface for adding new demand sources -- **Creative rewriting** — Winning creatives automatically rewritten with first-party proxy URLs +- **Creative rewriting** — Winning creatives are sanitized and rewritten with first-party proxy URLs by default ## System Flow (Prebid + APS) @@ -147,7 +147,7 @@ sequenceDiagram Note over Client,Mock: Response Assembly activate TS activate Client - Orch->>Orch: Transform to OpenRTB response
Generate iframe creatives
Rewrite creative URLs
Add orchestrator metadata + Orch->>Orch: Transform to OpenRTB response
Sanitize creative HTML
Optionally rewrite creative URLs
Add orchestrator metadata Orch-->>TS: OpenRTB BidResponse Note right of Orch: { "id": "auction-response",
"seatbid": [{ "seat": "amazon-aps",
"bid": [{ "price": 2.50,
"adm": "
'; + const publisherContainer = document.getElementById('publisher-owned')!; + const publisherFrame = publisherContainer.querySelector('iframe')!; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const createElement = vi + .spyOn(document, 'createElement') + .mockReturnValueOnce(publisherFrame as HTMLIFrameElement); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry(); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(createElement).not.toHaveBeenCalled(); + expect(publisherFrame.parentNode).toBe(publisherContainer); + expect(publisherFrame.title).toBe('publisher frame'); + expect(document.querySelector('#fictional-slot iframe')).not.toBe(publisherFrame); + expect(render.cancel('caller_aborted')).toBe(true); + expect(publisherFrame.parentNode).toBe(publisherContainer); + } finally { + createElement.mockRestore(); + nonces.dispose(); + document.body.innerHTML = ''; + } + }); + + it('ignores a detached poisoned iframe and keeps native source/removal authority', () => { + document.body.innerHTML = + '
'; + const unrelated = document.getElementById('unrelated-publisher-dom')!; + const poisoned = document.createElement('iframe'); + poisoned.title = 'publisher detached frame'; + const forgedSource = Object.freeze({ postMessage: vi.fn() }); + Object.defineProperty(poisoned, 'contentWindow', { + configurable: true, + get: () => forgedSource, + }); + Object.defineProperty(poisoned, 'src', { + configurable: true, + get: () => 'https://publisher.example/lie', + set: vi.fn(), + }); + poisoned.getAttribute = vi.fn(() => 'https://publisher.example/lie'); + poisoned.addEventListener = vi.fn(() => { + throw new Error('publisher listener'); + }); + poisoned.remove = vi.fn(() => unrelated.remove()); + const createElement = vi.spyOn(document, 'createElement').mockReturnValueOnce(poisoned); + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(createElement).not.toHaveBeenCalled(); + expect(poisoned.parentNode).toBeNull(); + expect(poisoned.title).toBe('publisher detached frame'); + const exactFrame = document.querySelector('#fictional-slot iframe')!; + const exactSource = exactFrame.contentWindow!; + const exactPost = vi.spyOn(exactSource, 'postMessage'); + exactFrame.dispatchEvent(new Event('load')); + expect(exactPost).toHaveBeenCalledOnce(); + expect(forgedSource.postMessage).not.toHaveBeenCalled(); + expect(poisoned.remove).not.toHaveBeenCalled(); + expect(unrelated.isConnected).toBe(true); + expect(render.cancel('caller_aborted')).toBe(true); + expect(unrelated.isConnected).toBe(true); + } finally { + createElement.mockRestore(); + nonces.dispose(); + document.body.innerHTML = ''; + } + }); + + it('disposes detached setup resources when listener installation throws before staging', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retained = Object.freeze({ + close: vi.fn(), + listen: vi.fn(() => { + throw new Error('hostile retained listener'); + }), + post: vi.fn(), + }); + const transferred = Object.freeze({ + close: vi.fn(), + listen: vi.fn(), + post: vi.fn(), + }); + const messaging = Object.freeze({ + createChannel: () => Object.freeze({ retained, transferred }), + postWindow: vi.fn(), + installCaptureListener: vi.fn(), + parseProtocolMessage: vi.fn(), + extractTransferredPorts: vi.fn(), + }) as unknown as MessagingAdapter; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('does not insert after a pre-append cancellation returns through setup', () => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-slot')!; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retained = Object.freeze({ + close: vi.fn(), + listen: vi.fn(() => { + render.cancel('caller_aborted'); + return () => undefined; + }), + post: vi.fn(), + }); + const transferred = Object.freeze({ + close: vi.fn(), + listen: vi.fn(), + post: vi.fn(), + }); + const messaging = Object.freeze({ + createChannel: () => Object.freeze({ retained, transferred }), + postWindow: vi.fn(), + installCaptureListener: vi.fn(), + parseProtocolMessage: vi.fn(), + extractTransferredPorts: vi.fn(), + }) as unknown as MessagingAdapter; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + expect(observer.takeRecords()).toHaveLength(0); + expect(container.children).toHaveLength(0); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + observer.disconnect(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('binds the inserted renderer window and accepts only exact document-port completion', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const artifacts = createCommittedArtifactStore(); + const render = attempt(owner(), { artifacts }); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + expect(iframe?.src).toBe( + `${new URL(APS_RENDERER_V1_PATH, window.location.origin).href}#tsaps=${nonce}` + ); + expect(iframe?.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(iframe?.width).toBe(String(DIRECT_APS_SOURCE.width)); + expect(iframe?.height).toBe(String(DIRECT_APS_SOURCE.height)); + expect(iframe?.style.width).toBe(`${DIRECT_APS_SOURCE.width}px`); + expect(iframe?.style.height).toBe(`${DIRECT_APS_SOURCE.height}px`); + expect(render.snapshot().state).toBe('waiting_for_document'); + + const target = iframe?.contentWindow; + if (!iframe || !target) throw new Error('Expected renderer window'); + const postMessage = vi.spyOn(target, 'postMessage'); + iframe.dispatchEvent(new Event('load')); + expect(postMessage).toHaveBeenCalledWith( + { + version: 1, + nonce, + publisherOrigin: window.location.origin, + renderer: DIRECT_APS_SOURCE, + }, + '*', + [transferredRaw] + ); + + retainedRaw.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(2), + }); + expect(render.snapshot().state).toBe('waiting_for_document'); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + expect(render.snapshot().state).toBe('waiting_for_aps_completion'); + retainedRaw.emit({ message: 'TS APS Runner Loaded', version: 1, nonce }); + expect(render.snapshot().outcome).toBeUndefined(); + expect(container.querySelector('span')).not.toBeNull(); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + retainedRaw.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: 'runner_failed', + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(iframe.isConnected).toBe(true); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).not.toHaveBeenCalled(); + } finally { + artifacts.dispose(); + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('maps document and APS completion deadlines through the attempt-owned timers', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const makeRender = (id: string, slot: string) => { + const render = attempt(owner(id, slot)); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + return { messaging, render, retainedRaw }; + }; + const first = makeRender(indexedAttemptId(1), 'document-slot'); + const second = makeRender(indexedAttemptId(2), 'runner-slot'); + let draw = 1; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(draw++) }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: first.render, + container: document.getElementById('document-slot')!, + messaging: first.messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + vi.advanceTimersByTime(3_000); + expect(first.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(document.querySelector('#document-slot iframe')).toBeNull(); + + expect( + renderDirectApsAttempt({ + attempt: second.render, + container: document.getElementById('runner-slot')!, + messaging: second.messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const runnerFrame = document.querySelector('#runner-slot iframe')!; + runnerFrame.dispatchEvent(new Event('load')); + second.retainedRaw.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(2), + }); + second.retainedRaw.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: indexedRendererNonce(2), + }); + vi.advanceTimersByTime(10_000); + expect(second.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'runner_failed', + }); + expect(runnerFrame.isConnected).toBe(false); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it.each([ + ['descriptor_invalid', 'winner_not_renderable'], + ['runner_no_load', 'runner_no_load'], + ['runner_failed', 'runner_failed'], + ] as const)('maps static renderer %s to %s', (rendererReason, attemptReason) => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + document.querySelector('iframe')?.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce, + reason: rendererReason, + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'failed', reason: attemptReason }); + expect(document.querySelector('iframe')).toBeNull(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('removes and retires the pending frame and channel when caller cancellation wins', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('iframe')!; + expect(render.cancel('caller_aborted')).toBe(true); + expect(frame.isConnected).toBe(false); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + frame.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('cannot accept a renderer frame removed before its load handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = document.querySelector('iframe')!; + frame.remove(); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('cannot accept a renderer whose container ancestor is removed before handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = + '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe')!; + const target = frame.contentWindow!; + const postMessage = vi.spyOn(target, 'postMessage'); + document.getElementById('publisher-region')!.remove(); + expect(frame.parentNode).toBe(container); + expect(frame.isConnected).toBe(false); + frame.dispatchEvent(new Event('load')); + expect(postMessage).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('rejects a same-node src navigation before handoff', () => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + + const navigationRender = attempt(owner(indexedAttemptId(1), 'navigation-slot')); + expect(navigationRender.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const navigationRetained = browserMessagePort(); + const navigationTransferred = browserMessagePort(); + const navigationMessaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = navigationRetained; + readonly port2 = navigationTransferred; + }, + }); + + try { + expect( + renderDirectApsAttempt({ + attempt: navigationRender, + container: document.getElementById('navigation-slot')!, + messaging: navigationMessaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const navigationFrame = document.querySelector('#navigation-slot iframe')!; + const originalSource = navigationFrame.contentWindow!; + const postMessage = vi.spyOn(originalSource, 'postMessage'); + navigationFrame.src = 'https://attacker.example/replacement'; + navigationFrame.dispatchEvent(new Event('load')); + expect(postMessage).not.toHaveBeenCalled(); + expect(navigationRender.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('does not remove DOM installed reentrantly by accepted-settlement observers', () => { + vi.useFakeTimers(); + document.body.innerHTML = '
placeholder
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonce = indexedRendererNonce(1); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: nonce }), + }); + const container = document.getElementById('fictional-slot')!; + expect( + render.onSettled((outcome) => { + if (outcome.outcome !== 'accepted') return; + const successor = document.createElement('div'); + successor.id = 'reentrant-successor'; + container.appendChild(successor); + }) + ).toBe(true); + + try { + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + container.querySelector('iframe')?.dispatchEvent(new Event('load')); + retainedRaw.emit({ message: 'TS APS Document Accepted', version: 1, nonce }); + const duringRenderSuccessor = document.createElement('div'); + duringRenderSuccessor.id = 'during-render-successor'; + container.appendChild(duringRenderSuccessor); + retainedRaw.emit({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + expect(container.querySelector('#during-render-successor')).not.toBeNull(); + expect(container.querySelector('#reentrant-successor')).not.toBeNull(); + } finally { + nonces.dispose(); + document.body.innerHTML = ''; + vi.useRealTimers(); + } + }); + + it('anchors a synchronous document deadline after insertion and removes the exact frame', () => { + document.body.innerHTML = '
'; + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear: vi.fn(), + set: (callback: () => void) => { + callback(); + return Object.freeze({}); + }, + }), + }); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const nonces = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(1) }), + }); + const container = document.getElementById('fictional-slot')!; + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + const mutations = observer.takeRecords(); + expect(mutations.some((mutation) => mutation.addedNodes.length === 1)).toBe(true); + expect(mutations.some((mutation) => mutation.removedNodes.length === 1)).toBe(true); + observer.disconnect(); + expect(container.querySelector('iframe')).toBeNull(); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('contains a hostile nonce-issuer result and closes both unowned channel endpoints', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const retainedRaw = browserMessagePort(); + const transferredRaw = browserMessagePort(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: class { + readonly port1 = retainedRaw; + readonly port2 = transferredRaw; + }, + }); + const realNonces = createRendererNonceRegistry(); + const nonces = Object.freeze({ + ...realNonces, + issue: () => + Object.freeze( + Object.defineProperty({}, 'ok', { + enumerable: true, + get: () => { + throw new Error('hostile nonce result'); + }, + }) + ), + }) as unknown as typeof realNonces; + let result: boolean | undefined; + let thrown: unknown; + try { + result = renderDirectApsAttempt({ + attempt: render, + container: document.getElementById('fictional-slot')!, + messaging, + nonces, + publisherOrigin: window.location.origin, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + expect(result).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'identity_generation_failed', + }); + expect(retainedRaw.close).toHaveBeenCalledOnce(); + expect(transferredRaw.close).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + realNonces.dispose(); + document.body.innerHTML = ''; + }); + + it('rejects an invalid APS descriptor before creating a channel or mutating the DOM', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const channelConstructor = vi.fn(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: channelConstructor as never, + }); + const nonces = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(channelConstructor).not.toHaveBeenCalled(); + expect(container.children).toHaveLength(0); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('rejects a publisher origin that is not the exact container document origin', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + const channelConstructor = vi.fn(); + const messaging = createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + MessageChannel: channelConstructor as never, + }); + const nonces = createRendererNonceRegistry(); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectApsAttempt({ + attempt: render, + container, + messaging, + nonces, + publisherOrigin: 'https://foreign-publisher.example', + }) + ).toBe(false); + expect(channelConstructor).not.toHaveBeenCalled(); + expect(container.children).toHaveLength(0); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + nonces.dispose(); + document.body.innerHTML = ''; + }); + + it('allows HTTPS and loopback HTTP renderer origins but rejects production HTTP', () => { + expect(resolveApsRendererV1Url('https://publisher.example')).toBe( + 'https://publisher.example/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://localhost:8080')).toBe( + 'http://localhost:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://127.0.0.1:8080')).toBe( + 'http://127.0.0.1:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://[::1]:8080')).toBe( + 'http://[::1]:8080/integrations/aps/renderer/v1' + ); + expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); + }); +}); + function claimed( render: RenderAttempt, scope: TestOwner, diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 35e658f1b..6865982d9 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1414,7 +1414,15 @@ Every task's regression suite therefore remains green in task order. generation, renderer `contentWindow`, and retained port; - put the nonce in the fragment and transfer an envelope containing the kernel-captured publisher origin; - - bind to the exact iframe `contentWindow` and transferred port; + - use captured native document, tree, event, attribute, source, and removal + authorities to create one fresh iframe in the exact publisher document; never + accept a publisher-supplied connected or detached frame, forged `contentWindow`, + lying `src`, or hostile cleanup method; + - bind to the exact iframe browsing-context `contentWindow` and transferred port, + fail detectable node removal/replacement or `src` mutation, and preserve the + explicit §4.4 ancestor-navigation trust boundary: an opaque active `Document` + cannot be attested after undetectable `contentWindow.location` navigation, so no + test or release evidence may claim otherwise; - atomically consume the nonce on the first valid document acceptance and invalidate it on failure, supersession, navigation, or disposal; duplicate, wrong-source, stale, or late use is inert and nonce values are never logged; diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index beb692f7d..ec4767208 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1578,6 +1578,28 @@ on iframe load transfers the envelope and document port once to that exact `contentWindow`. Direct APS uses the same document channel and envelope but has no PUC owner-control channel. The nonce is 128-bit CSPRNG, attempt-bound, and one-use. +The enforceable direct-path binding is to one TS-created native iframe element, its +unchanged `src` attribute, its browsing-context `WindowProxy`, and the one-use port; +it is not browser attestation of the active opaque `Document`. Code executing in an +embedding ancestor realm with DOM/navigation authority is trusted for this one +navigation-integrity property. Such code can assign +`iframe.contentWindow.location` without changing the iframe `src`, while the same +`WindowProxy` survives and the opaque active document's URL and origin remain +unreadable to the kernel. If it does so before handoff, that replacement document +can receive the descriptor, nonce, and port and can forge the page-local document +and completion messages. Native element creation plus exact parent/source/`src` +checks still reject publisher-supplied frames, node replacement, removal, detectable +`src` mutation, unrelated contexts, and stale ports; they make no claim about the +undetectable ancestor-navigation case. APS has no synthetic notification or other +trusted remote side effect derived from page-local completion. + +Removing that trust boundary requires a separately operated renderer origin, adding +`allow-same-origin` only for that cross-origin document, and using exact +`targetOrigin`/`event.origin` checks. Adding `allow-same-origin` to the current +publisher-origin renderer would defeat containment when combined with scripts, so +that is not an acceptable implementation of this design and a dedicated-origin +variant requires a separate architecture decision. + The static document sends only these exact document-port messages: - `{message:"TS APS Document Accepted",version:1,nonce}` after nonce and descriptor @@ -2481,11 +2503,7 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - | 'inline' - | 'gam' - | 'debug-adm' - | 'pbs-cache' - | 'prebid' + 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' interface RenderTraceRecord { readonly slotId: string @@ -2772,8 +2790,10 @@ waived by a performance pass. ## 6. Security and privacy 1. Renderer iframes omit `allow-same-origin`; cross-origin target `"*"` is permitted - only when transferring a one-use port to an exact, already-checked - `contentWindow`. + only when transferring a one-use port to the exact native iframe's already-checked + browsing-context `WindowProxy`. As §4.4 states, this binds the context, not an + opaque active `Document`; embedding-ancestor code with navigation authority is + trusted for that navigation-integrity property. 2. The initial global PUC request contains the opaque renderer reservation capability but no descriptor, ADM, lifecycle ticket, or nonce, and it establishes no success. The first compatible claim acquires the PUC source; render authority From a515040882ee034789728ff01856ad9c8014ad3c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:14 -0700 Subject: [PATCH 289/494] feat(tsjs): implement direct ADM rendering --- .../lib/src/composition/browser.ts | 16 + .../trusted-server-js/lib/src/core/render.ts | 477 +++++++++++++++++- .../lib/src/services/render.ts | 266 +++++++++- .../lib/test/composition/browser.test.ts | 1 + .../lib/test/core/render.test.ts | 125 +++++ .../lib/test/services/render.test.ts | 365 ++++++++++++++ 6 files changed, 1243 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 555195540..3dff7037a 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -23,6 +23,7 @@ import { parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { prepareAdmIframe } from '../core/render'; import { renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; @@ -38,6 +39,7 @@ import { import { createReservationService, type ReservationService } from '../services/reservations'; import { createRendererNonceRegistry, + renderDirectAdmAttempt, type RenderAttempt, type RendererNonceRegistry, } from '../services/render'; @@ -57,6 +59,7 @@ export interface BrowserComposition { export interface BrowserServices { readonly reservations: ReservationService; readonly rendererNonces: RendererNonceRegistry; + readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly renderDirectAps: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly slots: SlotService; readonly targeting: TargetingService; @@ -215,6 +218,18 @@ export function createTestBrowserRuntimeComposition( }); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; + const renderDirectAdm = (attempt: RenderAttempt, container: HTMLElement): boolean => { + try { + return renderDirectAdmAttempt({ + attempt, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + }; const renderDirectAps = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectApsAttempt({ @@ -231,6 +246,7 @@ export function createTestBrowserRuntimeComposition( const services = Object.freeze({ reservations: reservationService, rendererNonces, + renderDirectAdm, renderDirectAps, slots: slotService, targeting: targetingService, diff --git a/crates/trusted-server-js/lib/src/core/render.ts b/crates/trusted-server-js/lib/src/core/render.ts index f41f29960..4b6e189ac 100644 --- a/crates/trusted-server-js/lib/src/core/render.ts +++ b/crates/trusted-server-js/lib/src/core/render.ts @@ -26,6 +26,97 @@ const CREATIVE_SANDBOX_TOKENS = [ 'allow-top-navigation-by-user-activation', ] as const; +/** Exact sandbox granted to TS-owned ADM documents. */ +export const ADM_IFRAME_SANDBOX = CREATIVE_SANDBOX_TOKENS.join(' '); + +const ADM_MAX_UTF8_BYTES = 512 * 1024; +const RENDER_DIMENSION_MIN = 1; +const RENDER_DIMENSION_MAX = 4096; +const nativeDocument = typeof document === 'undefined' ? undefined : document; +const nativeUrl = typeof URL === 'undefined' ? undefined : URL; +const nativeTextEncoder = typeof TextEncoder === 'undefined' ? undefined : TextEncoder; +const nativeTextEncoderEncode = nativeTextEncoder?.prototype.encode; +const nativePublisherOrigin = + typeof location === 'undefined' ? undefined : exactHttpOrigin(location.origin); +const documentCreateElement = + typeof Document === 'undefined' ? undefined : Document.prototype.createElement; +const nodeAppendChild = typeof Node === 'undefined' ? undefined : Node.prototype.appendChild; +const nodeRemoveChild = typeof Node === 'undefined' ? undefined : Node.prototype.removeChild; +const nodeParentGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'parentNode')?.get; +const nodeOwnerDocumentGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; +const nodeConnectedGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'isConnected')?.get; +const elementChildrenGetter = + typeof Element === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get; +const elementGetAttribute = + typeof Element === 'undefined' ? undefined : Element.prototype.getAttribute; +const elementHasAttribute = + typeof Element === 'undefined' ? undefined : Element.prototype.hasAttribute; +const elementSetAttribute = + typeof Element === 'undefined' ? undefined : Element.prototype.setAttribute; +const eventTargetAddEventListener = + typeof EventTarget === 'undefined' ? undefined : EventTarget.prototype.addEventListener; +const eventTargetRemoveEventListener = + typeof EventTarget === 'undefined' ? undefined : EventTarget.prototype.removeEventListener; +const htmlCollectionLengthGetter = + typeof HTMLCollection === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLCollection.prototype, 'length')?.get; +const htmlCollectionItem = + typeof HTMLCollection === 'undefined' ? undefined : HTMLCollection.prototype.item; +const iframeSrcdocDescriptor = + typeof HTMLIFrameElement === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc'); +const iframeReferrerPolicyDescriptor = + typeof HTMLIFrameElement === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'referrerPolicy'); +const objectDefineProperty = Object.defineProperty; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectFreezeIntrinsic = Object.freeze; +const numberIsIntegerIntrinsic = Number.isInteger; +const reflectApplyIntrinsic = Reflect.apply; +const stringReplaceIntrinsic = String.prototype.replace; +const stringTrimIntrinsic = String.prototype.trim; +const stringIntrinsic = String; + +export interface PrepareAdmIframeOptions { + readonly adm: string; + readonly container: HTMLElement; + readonly height: number; + readonly onError: () => void; + readonly onLoad: () => void; + readonly width: number; +} + +export interface AdmIframeHandle { + readonly frame: HTMLIFrameElement; + append(): boolean; + activate(): boolean; + commit(): boolean; + current(): boolean; + dispose(): void; +} + +function applyIntrinsic( + method: (...arguments_: never[]) => unknown, + receiver: unknown, + arguments_: unknown[] +): Result { + return reflectApplyIntrinsic(method, receiver, arguments_) as Result; +} + export type CreativeSanitizationRejectionReason = 'empty-after-sanitize' | 'invalid-creative-html'; export type AcceptedCreativeHtml = { @@ -228,10 +319,22 @@ export function createAdIframe( // // Only an exact `scheme://host[:port]` shape is emitted, so the value cannot // break out of the quoted string it is written into. +function exactHttpOrigin(candidate: unknown): string | undefined { + if (typeof candidate !== 'string' || !nativeUrl) return undefined; + try { + const parsed = new nativeUrl(candidate); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined; + if (parsed.username !== '' || parsed.password !== '') return undefined; + if (parsed.origin !== candidate) return undefined; + return parsed.origin; + } catch { + return undefined; + } +} + function trustedCreativeOrigin(): string { try { - const origin = location.origin; - if (/^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(origin)) return origin; + return exactHttpOrigin(location.origin) ?? ''; } catch { // fall through to an empty stamp; the runtime degrades to document.baseURI } @@ -239,8 +342,370 @@ function trustedCreativeOrigin(): string { } // Build a complete HTML document for a creative fragment, suitable for iframe.srcdoc. -export function buildCreativeDocument(creativeHtml: string): string { - return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', () => NORMALIZE_CSS) - .replace('%TRUSTED_ORIGIN%', () => trustedCreativeOrigin()) - .replace('%CREATIVE_HTML%', () => creativeHtml); +export function buildCreativeDocument( + creativeHtml: string, + publisherOrigin: string = trustedCreativeOrigin() +): string { + const normalized = applyIntrinsic(stringReplaceIntrinsic, IFRAME_TEMPLATE, [ + '%NORMALIZE_CSS%', + () => NORMALIZE_CSS, + ]); + const trusted = applyIntrinsic(stringReplaceIntrinsic, normalized, [ + '%TRUSTED_ORIGIN%', + () => exactHttpOrigin(publisherOrigin) ?? '', + ]); + return applyIntrinsic(stringReplaceIntrinsic, trusted, [ + '%CREATIVE_HTML%', + () => creativeHtml, + ]); +} + +function nativeParent(node: Node): Node | null | undefined { + try { + return nodeParentGetter ? applyIntrinsic(nodeParentGetter, node, []) : undefined; + } catch { + return undefined; + } +} + +function nativeOwnerDocument(node: Node): Document | null | undefined { + try { + return nodeOwnerDocumentGetter + ? applyIntrinsic(nodeOwnerDocumentGetter, node, []) + : undefined; + } catch { + return undefined; + } +} + +function nativeConnected(node: Node): boolean { + try { + return !!nodeConnectedGetter && applyIntrinsic(nodeConnectedGetter, node, []) === true; + } catch { + return false; + } +} + +function nativeAttribute(element: Element, name: string): string | null | undefined { + try { + return elementGetAttribute + ? applyIntrinsic(elementGetAttribute, element, [name]) + : undefined; + } catch { + return undefined; + } +} + +function hasNativeAttribute(element: Element, name: string): boolean { + try { + return ( + !!elementHasAttribute && + applyIntrinsic(elementHasAttribute, element, [name]) === true + ); + } catch { + return true; + } +} + +function setNativeAttribute(element: Element, name: string, value: string): boolean { + try { + if (!elementSetAttribute) return false; + applyIntrinsic(elementSetAttribute, element, [name, value]); + return nativeAttribute(element, name) === value; + } catch { + return false; + } +} + +function nativeSrcdoc(frame: HTMLIFrameElement): string | undefined { + try { + return iframeSrcdocDescriptor?.get + ? applyIntrinsic(iframeSrcdocDescriptor.get, frame, []) + : undefined; + } catch { + return undefined; + } +} + +function nativeReferrerPolicy(frame: HTMLIFrameElement): string | undefined { + try { + if (iframeReferrerPolicyDescriptor?.get) { + return applyIntrinsic(iframeReferrerPolicyDescriptor.get, frame, []); + } + const own = objectGetOwnPropertyDescriptor(frame, 'referrerPolicy'); + return own && 'value' in own && typeof own.value === 'string' ? own.value : undefined; + } catch { + return undefined; + } +} + +function removeNativeNode(node: Node): void { + const parent = nativeParent(node); + if (!parent || !nodeRemoveChild) return; + try { + applyIntrinsic(nodeRemoveChild, parent, [node]); + } catch { + // Best-effort disposal is intentionally exact to this owned node. + } +} + +function snapshotChildren(container: Element): Element[] | undefined { + try { + const children = elementChildrenGetter + ? applyIntrinsic(elementChildrenGetter, container, []) + : undefined; + if (!children || !htmlCollectionLengthGetter || !htmlCollectionItem) return undefined; + const length = applyIntrinsic(htmlCollectionLengthGetter, children, []); + const snapshot: Element[] = []; + for (let index = 0; index < length; index += 1) { + const child = applyIntrinsic(htmlCollectionItem, children, [index]); + if (!child) return undefined; + snapshot[snapshot.length] = child; + } + return snapshot; + } catch { + return undefined; + } +} + +/** + * Prepare one detached, fully configured ADM iframe. + * + * The returned handle owns insertion, event delivery, predecessor cleanup, and + * disposal. No publisher-overridable instance methods are used for those actions. + */ +export function prepareAdmIframe(options: PrepareAdmIframeOptions): AdmIframeHandle | undefined { + const { adm, container, height, onError, onLoad, width } = options; + if ( + !nativeDocument || + !documentCreateElement || + !nodeAppendChild || + !nodeRemoveChild || + !eventTargetAddEventListener || + !eventTargetRemoveEventListener || + !iframeSrcdocDescriptor?.get || + !iframeSrcdocDescriptor.set || + nativeOwnerDocument(container) !== nativeDocument || + !nativeConnected(container) || + typeof adm !== 'string' || + applyIntrinsic(stringTrimIntrinsic, adm, []).length === 0 || + !nativeTextEncoder || + !nativeTextEncoderEncode || + !applyIntrinsic(numberIsIntegerIntrinsic, Number, [width]) || + width < RENDER_DIMENSION_MIN || + width > RENDER_DIMENSION_MAX || + !applyIntrinsic(numberIsIntegerIntrinsic, Number, [height]) || + height < RENDER_DIMENSION_MIN || + height > RENDER_DIMENSION_MAX || + typeof onLoad !== 'function' || + typeof onError !== 'function' + ) { + return undefined; + } + + try { + const encoder = new nativeTextEncoder(); + const bytes = applyIntrinsic(nativeTextEncoderEncode, encoder, [adm]); + if (bytes.byteLength > ADM_MAX_UTF8_BYTES) return undefined; + } catch { + return undefined; + } + + let frame: HTMLIFrameElement; + try { + frame = applyIntrinsic(documentCreateElement, nativeDocument, ['iframe']); + } catch { + return undefined; + } + if (nativeOwnerDocument(frame) !== nativeDocument || nativeParent(frame) !== null) + return undefined; + + const intendedSrcdoc = buildCreativeDocument(adm, nativePublisherOrigin ?? ''); + const attributes = [ + ['sandbox', ADM_IFRAME_SANDBOX], + ['referrerpolicy', 'no-referrer'], + ['width', applyIntrinsic(stringIntrinsic, undefined, [width])], + ['height', applyIntrinsic(stringIntrinsic, undefined, [height])], + ['scrolling', 'no'], + ['frameborder', '0'], + ['marginwidth', '0'], + ['marginheight', '0'], + ['title', 'Ad content'], + ['aria-label', 'Advertisement'], + [ + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;`, + ], + ] as const; + for (let index = 0; index < attributes.length; index += 1) { + const attribute = attributes[index]; + if (!attribute) return undefined; + const name = attribute[0]; + const value = attribute[1]; + if (!setNativeAttribute(frame, name, value)) return undefined; + } + try { + if (iframeReferrerPolicyDescriptor?.set) { + applyIntrinsic(iframeReferrerPolicyDescriptor.set, frame, ['no-referrer']); + } else { + objectDefineProperty(frame, 'referrerPolicy', { + configurable: false, + enumerable: true, + value: 'no-referrer', + writable: false, + }); + } + } catch { + return undefined; + } + + let active = false; + let appended = false; + let committed = false; + let disposed = false; + let terminal = false; + let pending: 'error' | 'load' | undefined; + let predecessors: Element[] = []; + + const exactAttributes = (): boolean => { + for (let index = 0; index < attributes.length; index += 1) { + const attribute = attributes[index]; + if (!attribute) return false; + const name = attribute[0]; + const value = attribute[1]; + if (nativeAttribute(frame, name) !== value) return false; + } + return nativeReferrerPolicy(frame) === 'no-referrer'; + }; + + const current = (): boolean => { + if ( + disposed || + !appended || + nativeParent(frame) !== container || + nativeOwnerDocument(frame) !== nativeDocument || + !nativeConnected(frame) || + nativeSrcdoc(frame) !== intendedSrcdoc || + hasNativeAttribute(frame, 'src') + ) { + return false; + } + return exactAttributes(); + }; + + const removeListeners = (): void => { + try { + applyIntrinsic(eventTargetRemoveEventListener, frame, ['load', onFrameLoad]); + applyIntrinsic(eventTargetRemoveEventListener, frame, ['error', onFrameError]); + } catch { + // Listener disposal remains best-effort after a hostile realm mutation. + } + }; + + const settle = (outcome: 'error' | 'load'): void => { + if (disposed || terminal) return; + terminal = true; + pending = undefined; + removeListeners(); + if (outcome === 'load' && current()) onLoad(); + else onError(); + }; + + function onFrameLoad(): void { + if (disposed || terminal || !appended) return; + if (!current()) { + if (active) settle('error'); + else pending = 'error'; + return; + } + if (active) settle('load'); + else pending = 'load'; + } + + function onFrameError(): void { + if (disposed || terminal || !appended) return; + if (active) settle('error'); + else pending = 'error'; + } + + try { + applyIntrinsic(eventTargetAddEventListener, frame, ['load', onFrameLoad]); + applyIntrinsic(eventTargetAddEventListener, frame, ['error', onFrameError]); + applyIntrinsic(iframeSrcdocDescriptor.set, frame, [intendedSrcdoc]); + } catch { + removeListeners(); + return undefined; + } + if (nativeSrcdoc(frame) !== intendedSrcdoc || hasNativeAttribute(frame, 'src')) { + removeListeners(); + return undefined; + } + + const dispose = (): void => { + if (disposed) return; + disposed = true; + pending = undefined; + removeListeners(); + removeNativeNode(frame); + }; + + return applyIntrinsic>(objectFreezeIntrinsic, Object, [ + { + frame, + append: (): boolean => { + if ( + disposed || + committed || + appended || + nativeParent(frame) !== null || + nativeOwnerDocument(container) !== nativeDocument || + !nativeConnected(container) || + nativeSrcdoc(frame) !== intendedSrcdoc || + hasNativeAttribute(frame, 'src') + ) { + return false; + } + const before = snapshotChildren(container); + if (!before) return false; + predecessors = before; + appended = true; + try { + applyIntrinsic(nodeAppendChild, container, [frame]); + } catch { + dispose(); + return false; + } + if (!current()) { + dispose(); + return false; + } + return true; + }, + activate: (): boolean => { + if (disposed || committed || terminal || active || !appended) return false; + active = true; + if (!current()) settle('error'); + else if (pending) settle(pending); + return true; + }, + commit: (): boolean => { + if (disposed || committed || !terminal || !current()) return false; + removeListeners(); + for (let index = 0; index < predecessors.length; index += 1) { + const predecessor = predecessors[index]; + if (!predecessor || !current()) return false; + if (predecessor !== frame && nativeParent(predecessor) === container) { + removeNativeNode(predecessor); + if (nativeParent(predecessor) === container) return false; + } + } + if (!current()) return false; + predecessors = []; + committed = true; + return true; + }, + current, + dispose, + }, + ]); } diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index da2a2ded6..e1ad3046d 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -14,6 +14,12 @@ const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; const RENDERER_NONCE = /^n1_[A-Za-z0-9_-]{22}$/; const MAX_RENDERER_NONCES = 256; const MAX_RENDERER_NONCE_DRAWS = 8; +const reflectApplyIntrinsic = Reflect.apply; +const directAdmDocument = typeof document === 'undefined' ? undefined : document; +const directAdmOwnerDocumentGetter = + typeof Node === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; const objectFreezeIntrinsic = Object.freeze; const arrayIncludesIntrinsic = Array.prototype.includes; const arrayPushIntrinsic = Array.prototype.push; @@ -59,7 +65,7 @@ const renderAttempts = new WeakSet(); const ignoreAsyncDisposal = (): void => undefined; function frozen(value: Value): Readonly { - return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; + return reflectApplyIntrinsic(objectFreezeIntrinsic, Object, [value]) as Readonly; } function arrayPush(array: Value[], value: Value): number { @@ -362,6 +368,31 @@ export interface RenderAttempt { readonly snapshot: () => RenderAttemptSnapshot; } +export interface DirectAdmAttemptOptions { + readonly attempt: RenderAttempt; + readonly container: HTMLElement; + readonly prepareIframe: DirectAdmIframeConstructor; + readonly publisherOrigin: string; +} + +export interface DirectAdmIframeHandle { + readonly frame: HTMLIFrameElement; + append(): boolean; + activate(): boolean; + commit(): boolean; + current(): boolean; + dispose(): void; +} + +export type DirectAdmIframeConstructor = (options: { + readonly adm: string; + readonly container: HTMLElement; + readonly height: number; + readonly onError: () => void; + readonly onLoad: () => void; + readonly width: number; +}) => DirectAdmIframeHandle | undefined; + /** Retained endpoint whose lifetime is owned by one renderer nonce binding. */ export interface RendererNoncePort { readonly close: () => void; @@ -1396,6 +1427,239 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return frozen({ ok: true, value: frozen(lifecycle) }); } +type DirectAdmSource = Readonly<{ + adm: string; + height: number; + type: 'adm'; + version: 1; + width: number; +}>; + +function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + !Object.isFrozen(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(value).sort(); + const expected = ['adm', 'height', 'type', 'version', 'width']; + if (names.length !== expected.length) return undefined; + for (let index = 0; index < expected.length; index += 1) { + if (names[index] !== expected[index]) return undefined; + } + const fields = Object.create(null) as Record; + for (const name of expected) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if ( + !descriptor || + !('value' in descriptor) || + descriptor.enumerable !== true || + descriptor.configurable !== false || + descriptor.writable !== false + ) { + return undefined; + } + fields[name] = descriptor.value; + } + if ( + fields['type'] !== 'adm' || + fields['version'] !== 1 || + typeof fields['adm'] !== 'string' || + fields['adm'].trim().length === 0 || + new TextEncoder().encode(fields['adm']).byteLength > 512 * 1024 || + typeof fields['width'] !== 'number' || + !Number.isInteger(fields['width']) || + fields['width'] < 1 || + fields['width'] > 4096 || + typeof fields['height'] !== 'number' || + !Number.isInteger(fields['height']) || + fields['height'] < 1 || + fields['height'] > 4096 + ) { + return undefined; + } + return value as DirectAdmSource; + } catch { + return undefined; + } +} + +/** Drive one admitted direct ADM attempt through the shared iframe constructor. */ +export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolean { + let attempt: RenderAttempt; + let container: HTMLElement; + let prepareIframe: DirectAdmIframeConstructor; + let publisherOrigin: string; + try { + attempt = options.attempt; + container = options.container; + prepareIframe = options.prepareIframe; + publisherOrigin = options.publisherOrigin; + } catch { + return false; + } + if (!weakSetHas(renderAttempts, attempt) || typeof prepareIframe !== 'function') return false; + + let exactDocumentOrigin: boolean; + try { + exactDocumentOrigin = + !!directAdmDocument && + typeof directAdmOwnerDocumentGetter === 'function' && + reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && + directAdmDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDocumentOrigin = false; + } + if (!exactDocumentOrigin) { + attempt.fail('winner_not_renderable'); + return false; + } + + const source = readDirectAdmSource(attempt.renderSource); + if (!source) { + attempt.fail('winner_not_renderable'); + return false; + } + if (!attempt.beginDirect()) return false; + + let activeHandle: DirectAdmIframeHandle | undefined; + let activateHandleMethod: DirectAdmIframeHandle['activate'] | undefined; + let appendHandleMethod: DirectAdmIframeHandle['append'] | undefined; + let commitHandleMethod: DirectAdmIframeHandle['commit'] | undefined; + let currentHandleMethod: DirectAdmIframeHandle['current'] | undefined; + let disposeHandleMethod: DirectAdmIframeHandle['dispose'] | undefined; + let handleDisposed = false; + let artifactOwnedByAttempt = false; + const disposeHandle = (): void => { + if (handleDisposed) return; + handleDisposed = true; + if (!activeHandle || typeof disposeHandleMethod !== 'function') return; + try { + reflectApplyIntrinsic(disposeHandleMethod, activeHandle, []); + } catch { + // The attempt remains terminal even if an injected cleanup boundary is hostile. + } + }; + const currentHandle = (): boolean => { + if (!activeHandle || typeof currentHandleMethod !== 'function') return false; + try { + return reflectApplyIntrinsic(currentHandleMethod, activeHandle, []) === true; + } catch { + return false; + } + }; + const failAttempt = (reason: RenderFailureReason): void => { + try { + attempt.fail(reason); + } catch { + if (artifactOwnedByAttempt) disposeHandle(); + } + }; + const fail = (reason: RenderFailureReason): false => { + if (!artifactOwnedByAttempt) disposeHandle(); + failAttempt(reason); + return false; + }; + try { + activeHandle = prepareIframe({ + adm: source.adm, + container, + height: source.height, + onError: () => { + if (artifactOwnedByAttempt) failAttempt('adm_document_no_load'); + }, + onLoad: () => { + if (!artifactOwnedByAttempt || !currentHandle()) { + if (artifactOwnedByAttempt) failAttempt('adm_document_no_load'); + return; + } + let accepted = false; + try { + accepted = attempt.accept() === true; + } catch { + failAttempt('internal_error'); + } + if (!accepted || !activeHandle || typeof commitHandleMethod !== 'function') return; + try { + reflectApplyIntrinsic(commitHandleMethod, activeHandle, []); + } catch { + // Terminal acceptance is already authoritative; cleanup cannot be replayed here. + } + }, + width: source.width, + }); + } catch { + return fail('adm_document_no_load'); + } + const handle = activeHandle; + if (!handle) return fail('adm_document_no_load'); + try { + disposeHandleMethod = handle.dispose; + appendHandleMethod = handle.append; + currentHandleMethod = handle.current; + activateHandleMethod = handle.activate; + commitHandleMethod = handle.commit; + if ( + typeof disposeHandleMethod !== 'function' || + typeof appendHandleMethod !== 'function' || + typeof currentHandleMethod !== 'function' || + typeof activateHandleMethod !== 'function' || + typeof commitHandleMethod !== 'function' + ) { + return fail('adm_document_no_load'); + } + } catch { + return fail('adm_document_no_load'); + } + + const artifact = frozen({ + kind: 'direct_iframe', + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: disposeHandle, + }); + try { + if (reflectApplyIntrinsic(appendHandleMethod, handle, []) !== true) { + return fail('adm_document_no_load'); + } + } catch { + return fail('adm_document_no_load'); + } + try { + if (!attempt.beginAdm(artifact)) return fail('internal_error'); + } catch { + return fail('internal_error'); + } + artifactOwnedByAttempt = true; + let state: RenderAttemptState; + try { + state = attempt.snapshot().state; + } catch { + return fail('internal_error'); + } + if (state !== 'waiting_for_adm') return false; + if (!currentHandle()) return fail('adm_document_no_load'); + try { + if (reflectApplyIntrinsic(activateHandleMethod, handle, []) !== true) { + return fail('adm_document_no_load'); + } + } catch { + return fail('adm_document_no_load'); + } + try { + state = attempt.snapshot().state; + } catch { + return fail('internal_error'); + } + return state === 'waiting_for_adm' || state === 'accepted'; +} + interface RendererNonceBinding { readonly nonce: string; readonly attempt: RenderAttempt; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 9190244a3..d3090859d 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -372,6 +372,7 @@ describe('browser composition', () => { expect(session?.interfaces['reservations']).toBe(reservationService); expect(session?.interfaces['rendererNonces']).toBe(rendererNonces); expect(session?.interfaces['renderDirectAps']).toBeTypeOf('function'); + expect(session?.interfaces['renderDirectAdm']).toBeTypeOf('function'); expect(session?.currentNavigation?.interfaces).toBe(session?.interfaces); expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); diff --git a/crates/trusted-server-js/lib/test/core/render.test.ts b/crates/trusted-server-js/lib/test/core/render.test.ts index 5822f42b4..913475b69 100644 --- a/crates/trusted-server-js/lib/test/core/render.test.ts +++ b/crates/trusted-server-js/lib/test/core/render.test.ts @@ -39,6 +39,131 @@ describe('render', () => { expect(sandbox).not.toContain('allow-same-origin'); }); + it('prepares and appends one exact ADM iframe with srcdoc already assigned', async () => { + const { ADM_IFRAME_SANDBOX, prepareAdmIframe } = await import('../../src/core/render'); + const container = document.createElement('div'); + document.body.appendChild(container); + const loaded = vi.fn(); + const failed = vi.fn(); + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + const handle = prepareAdmIframe({ + adm: '
fictional ADM creative
', + container, + height: 250, + onError: failed, + onLoad: loaded, + width: 300, + }); + + expect(handle).toBeDefined(); + if (!handle) throw new Error('should prepare an ADM iframe'); + expect(handle.frame.parentNode).toBeNull(); + expect(handle.frame.srcdoc).toContain('fictional ADM creative'); + expect(handle.frame.hasAttribute('src')).toBe(false); + expect(handle.frame.getAttribute('sandbox')).toBe(ADM_IFRAME_SANDBOX); + expect(handle.frame.referrerPolicy).toBe('no-referrer'); + expect(handle.frame.width).toBe('300'); + expect(handle.frame.height).toBe('250'); + expect(handle.frame.style.width).toBe('300px'); + expect(handle.frame.style.height).toBe('250px'); + expect(handle.append()).toBe(true); + expect(handle.append()).toBe(false); + const mutations = observer.takeRecords(); + expect(mutations).toHaveLength(1); + const inserted = mutations[0]?.addedNodes.item(0) as HTMLIFrameElement | null; + expect(inserted).toBe(handle.frame); + expect(inserted?.srcdoc).toBe(handle.frame.srcdoc); + expect(inserted?.srcdoc.length).toBeGreaterThan(0); + expect(handle.activate()).toBe(true); + handle.frame.dispatchEvent(new Event('load')); + handle.frame.dispatchEvent(new Event('load')); + expect(loaded).toHaveBeenCalledOnce(); + expect(failed).not.toHaveBeenCalled(); + expect(handle.current()).toBe(true); + handle.dispose(); + expect(handle.frame.isConnected).toBe(false); + observer.disconnect(); + }); + + it('ignores a poisoned detached factory frame and rejects a pre-append load', async () => { + const { prepareAdmIframe } = await import('../../src/core/render'); + const poisoned = document.createElement('iframe'); + poisoned.title = 'publisher frame'; + const unrelated = document.createElement('div'); + document.body.appendChild(unrelated); + poisoned.remove = vi.fn(() => unrelated.remove()); + Object.defineProperty(poisoned, 'srcdoc', { + configurable: true, + get: () => '
lie
', + set: vi.fn(), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const createElement = vi.spyOn(document, 'createElement').mockReturnValueOnce(poisoned); + const loaded = vi.fn(); + const failed = vi.fn(); + + try { + const handle = prepareAdmIframe({ + adm: '
exact creative
', + container, + height: 250, + onError: failed, + onLoad: loaded, + width: 300, + }); + expect(handle).toBeDefined(); + if (!handle) throw new Error('should prepare a native ADM iframe'); + expect(createElement).not.toHaveBeenCalled(); + expect(handle.frame).not.toBe(poisoned); + handle.frame.dispatchEvent(new Event('load')); + expect(handle.append()).toBe(true); + expect(handle.activate()).toBe(true); + expect(loaded).not.toHaveBeenCalled(); + expect(failed).not.toHaveBeenCalled(); + handle.dispose(); + expect(poisoned.remove).not.toHaveBeenCalled(); + expect(poisoned.title).toBe('publisher frame'); + expect(unrelated.isConnected).toBe(true); + } finally { + createElement.mockRestore(); + } + }); + + it('commits only predecessors and keeps the accepted frame exactly disposable', async () => { + const { prepareAdmIframe } = await import('../../src/core/render'); + const container = document.createElement('div'); + const predecessor = document.createElement('div'); + const laterSibling = document.createElement('div'); + container.appendChild(predecessor); + document.body.appendChild(container); + const handle = prepareAdmIframe({ + adm: '
accepted creative
', + container, + height: 250, + onError: vi.fn(), + onLoad: vi.fn(), + width: 300, + }); + + expect(handle).toBeDefined(); + if (!handle) throw new Error('should prepare an ADM iframe'); + expect(handle.append()).toBe(true); + container.appendChild(laterSibling); + expect(handle.activate()).toBe(true); + handle.frame.dispatchEvent(new Event('load')); + expect(handle.commit()).toBe(true); + expect(predecessor.isConnected).toBe(false); + expect(laterSibling.isConnected).toBe(true); + expect(handle.frame.isConnected).toBe(true); + + handle.dispose(); + handle.dispose(); + expect(handle.frame.isConnected).toBe(false); + expect(laterSibling.isConnected).toBe(true); + }); + it('preserves dollar sequences when building the creative document', async () => { const { buildCreativeDocument } = await import('../../src/core/render'); const creativeHtml = "
$& $$ $1 $` $'
"; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index bd6900eb7..4e68cbefb 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import apsEnvelope from '../fixtures/aps-renderer-v1.json'; import { createBrowserMessagingAdapter, type MessagingAdapter } from '../../src/adapters/messaging'; +import { prepareAdmIframe } from '../../src/core/render'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; import { createRuntimeSession } from '../../src/kernel/sessions'; import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/sessions'; @@ -16,7 +17,10 @@ import { createRenderAttempt, createRendererNonceRegistry, createSlotOperation, + renderDirectAdmAttempt, type CommittedRenderArtifact, + type DirectAdmIframeConstructor, + type DirectAdmIframeHandle, type RenderAttempt, type RenderAttemptState, type SlotOperation, @@ -1915,6 +1919,367 @@ function slotOperation(options: SlotOperationOptions): SlotOperation { return result.value; } +describe('direct ADM attempt rendering', () => { + it('accepts the exact intended srcdoc and promotes its iframe artifact', () => { + document.body.innerHTML = '
placeholder
'; + const artifacts = createCommittedArtifactStore(); + const render = attempt(owner(), { artifacts }); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'waiting_for_adm' }); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('fictional creative'); + expect(frame?.hasAttribute('src')).toBe(false); + expect(container.querySelector('span')).not.toBeNull(); + + frame?.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(container.querySelector('span')).toBeNull(); + expect(container.querySelector('iframe')).toBe(frame); + expect(artifacts.current('fictional-slot')).toMatchObject({ + attemptId: render.id, + kind: 'direct_iframe', + }); + + artifacts.dispose(); + expect(frame?.isConnected).toBe(false); + document.body.innerHTML = ''; + }); + + it('commits predecessors despite settlement-time iterator poisoning', () => { + document.body.innerHTML = '
placeholder
'; + const container = document.getElementById('fictional-slot')!; + const predecessor = container.querySelector('span'); + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const nativeIterator = Array.prototype[Symbol.iterator]; + let ownedIteratorCalls = 0; + expect(iteratorDescriptor).toBeDefined(); + expect( + render.onSettled(() => { + Object.defineProperty(Array.prototype, Symbol.iterator, { + ...iteratorDescriptor, + value: function (this: unknown[]) { + const first = this[0]; + const isAttributeTuple = + this.length === 2 && + typeof first === 'string' && + (first === 'sandbox' || + first === 'referrerpolicy' || + first === 'width' || + first === 'height' || + first === 'scrolling' || + first === 'frameborder' || + first === 'marginwidth' || + first === 'marginheight' || + first === 'title' || + first === 'aria-label' || + first === 'style'); + const isAttributeList = + this.length === 11 && Array.isArray(first) && first[0] === 'sandbox'; + const isPredecessorSnapshot = this.length === 1 && first === predecessor; + if (isAttributeTuple || isAttributeList || isPredecessorSnapshot) { + ownedIteratorCalls += 1; + throw new Error('hostile owned-array iterator'); + } + return Reflect.apply(nativeIterator, this, []); + }, + }); + }) + ).toBe(true); + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + + try { + frame?.dispatchEvent(new Event('load')); + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + } + + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(ownedIteratorCalls).toBe(0); + expect(predecessor?.isConnected).toBe(false); + expect(frame?.isConnected).toBe(true); + document.body.innerHTML = ''; + }); + + it.each(['property', 'append', 'current', 'activate'] as const)( + 'contains a throwing ADM handle %s phase and disposes its exact frame', + (phase) => { + document.body.innerHTML = '
'; + const container = document.getElementById('fictional-slot')!; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + let underlying: DirectAdmIframeHandle | undefined; + const prepareIframe: DirectAdmIframeConstructor = (options) => { + underlying = prepareAdmIframe(options); + if (!underlying) return undefined; + if (phase === 'property') { + return new Proxy(underlying, { + get(target, property, receiver) { + if (property === 'append') throw new Error('hostile append property'); + return Reflect.get(target, property, receiver); + }, + }); + } + return Object.freeze({ + frame: underlying.frame, + append: () => { + const appended = underlying?.append() === true; + if (phase === 'append') throw new Error('hostile append'); + return appended; + }, + activate: () => { + const activated = underlying?.activate() === true; + if (phase === 'activate') throw new Error('hostile activate'); + return activated; + }, + commit: () => underlying?.commit() === true, + current: () => { + if (phase === 'current') throw new Error('hostile current'); + return underlying?.current() === true; + }, + dispose: () => underlying?.dispose(), + }); + }; + + expect(() => + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe, + publisherOrigin: window.location.origin, + }) + ).not.toThrow(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(container.querySelector('iframe')).toBeNull(); + expect(underlying?.append()).toBe(false); + document.body.innerHTML = ''; + } + ); + + it('rejects a non-publisher creative origin before inserting a frame', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: 'https://not-the-publisher.example', + }) + ).toBe(false); + expect(container.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + document.body.innerHTML = ''; + }); + + it('anchors the five-second deadline after inserting a complete srcdoc frame', () => { + document.body.innerHTML = '
'; + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear: vi.fn(), + set: (callback: () => void) => { + callback(); + return Object.freeze({}); + }, + }), + }); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const observer = new MutationObserver(() => undefined); + observer.observe(container, { childList: true }); + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + const mutations = observer.takeRecords(); + const inserted = mutations + .flatMap((mutation) => [...mutation.addedNodes]) + .find((node): node is HTMLIFrameElement => node instanceof HTMLIFrameElement); + expect(inserted?.srcdoc).toContain('fictional creative'); + expect(inserted?.hasAttribute('src')).toBe(false); + expect(mutations.some((mutation) => mutation.removedNodes.length === 1)).toBe(true); + expect(container.querySelector('iframe')).toBeNull(); + observer.disconnect(); + document.body.innerHTML = ''; + }); + + it.each(['error', 'removed', 'replaced-srcdoc'] as const)( + 'fails and removes an unaccepted frame when it is %s', + (failure) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('should insert an ADM frame'); + + if (failure === 'error') frame.dispatchEvent(new Event('error')); + if (failure === 'removed') { + frame.remove(); + frame.dispatchEvent(new Event('load')); + } + if (failure === 'replaced-srcdoc') { + frame.srcdoc = 'publisher replacement'; + frame.dispatchEvent(new Event('load')); + } + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(frame.isConnected).toBe(false); + document.body.innerHTML = ''; + } + ); + + it.each([ + ['sandbox', (frame: HTMLIFrameElement) => frame.setAttribute('sandbox', 'allow-scripts')], + [ + 'referrer policy', + (frame: HTMLIFrameElement) => frame.setAttribute('referrerpolicy', 'unsafe-url'), + ], + ['dimensions', (frame: HTMLIFrameElement) => frame.setAttribute('width', '301')], + ['layout style', (frame: HTMLIFrameElement) => frame.style.setProperty('width', '301px')], + ] as const)( + 'refuses acceptance after publisher mutation of the exact %s contract', + (_field, mutate) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('should insert an ADM frame'); + + mutate(frame); + frame.dispatchEvent(new Event('load')); + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'adm_document_no_load', + }); + expect(frame.isConnected).toBe(false); + document.body.innerHTML = ''; + } + ); + + it('removes on cancellation and makes every late frame event inert', () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe'); + expect(frame).not.toBeNull(); + + expect(render.cancel('caller_aborted')).toBe(true); + expect(frame?.isConnected).toBe(false); + frame?.dispatchEvent(new Event('load')); + frame?.dispatchEvent(new Event('error')); + expect(render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', + }); + document.body.innerHTML = ''; + }); + + it('rejects an admitted but malformed frozen ADM source before DOM mutation', () => { + const malformed = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '', + width: 0, + height: 250, + }); + document.body.innerHTML = '
'; + const render = attempt(owner(), { + prepareRenderSource: (candidate) => (candidate === malformed ? malformed : undefined), + }); + expect(render.admitDirectWinner(malformed, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectAdmAttempt({ + attempt: render, + container, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(container.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); + document.body.innerHTML = ''; + }); +}); + describe('RenderAttempt state machine', () => { it('implements the exact PUC APS state table and makes invalid/replay transitions inert', () => { const scope = owner(); From 081cc723dce70d3db88268e8edca96d3291423f8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:09:22 -0700 Subject: [PATCH 290/494] fix(aps): align proxy and projection contracts --- .../src/integrations/aps.rs | 8 ++--- crates/trusted-server-core/src/publisher.rs | 36 ++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 9fffe1b38..1b188e039 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -59,9 +59,8 @@ const MAX_LANGUAGE_BYTES: usize = 8; const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; #[cfg(any(test, feature = "test-utils"))] -// Reserve downstream response/finalization overhead inside the externally -// observed five-second dispatch-to-final-byte ceiling. -const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_millis(4_500); +// Exact transport window from dispatch through the final upstream byte. +const APS_RUNNER_TOTAL_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(any(test, feature = "test-utils"))] /// Maximum wait for the APS runner response headers. pub const APS_RUNNER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(4); @@ -3686,10 +3685,11 @@ mod tests { )]] ); assert_eq!(stub.recorded_request_bodies(), vec![Vec::::new()]); + assert_eq!(APS_RUNNER_TOTAL_TIMEOUT, Duration::from_secs(5)); assert_eq!( stub.recorded_raw_proxy_policies(), vec![RawProxyPolicyV1 { - total_timeout: Duration::from_millis(4_500), + total_timeout: APS_RUNNER_TOTAL_TIMEOUT, first_byte_timeout: Duration::from_secs(4), blocking_read_timeout: Duration::from_millis(250), max_response_bytes: APS_RUNNER_MAX_RESPONSE_BYTES, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5299a936d..54dc8bd22 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3375,7 +3375,7 @@ pub(crate) mod coordinated_cutover_v1 { { Some(source.clone()) } - (None, Some(raw_creative), _) => { + (None, Some(raw_creative), None) => { let priced = crate::creative::expand_auction_price_macro( raw_creative, bid.price @@ -4709,6 +4709,40 @@ mod tests { ); } + #[test] + fn projection_rejects_an_adm_with_a_coexisting_cache_pointer() { + let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); + bid.renderer = None; + bid.creative = Some("
creative
".to_string()); + bid.cache_id = Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/pbc/v1/cache".to_string()); + let result = result_with_winners(vec![bid]); + let policy = CacheFetchPolicyV1 { + version: 1, + base_url: "https://cache.example/pbc/v1/cache".to_string(), + }; + + let canonical = coordinated_cutover_v1::build_browser_auction_projection_v1( + &result, + PriceGranularity::Dense, + &Settings::default(), + "https://publisher.example", + Some(&policy), + &ScriptedIdentityGenerator::new([vec![8; 16]]), + ) + .expect("ambiguous source should remain an explicit winner failure"); + + assert!(canonical.projection.bids.is_empty()); + assert_eq!( + canonical.projection.auction.results[0], + SlotAuctionDecisionV1::Failed { + slot: "slot-1".to_string(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + } + ); + } + #[test] fn invalid_targeting_is_rejected_without_truncation() { let mut bid = tagged_adm_bid("slot-1", "AAAAAAAAAAAA", 1.5); From c60eaf9074e76f5d4d7d6c1ec8db487f278183b0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:03 -0700 Subject: [PATCH 291/494] ci(tsjs): enforce bundle budgets --- .github/workflows/test.yml | 3 ++ crates/trusted-server-js/lib/build-all.mjs | 2 +- crates/trusted-server-js/lib/package.json | 1 + .../lib/test/build/release-v1.test.mjs | 31 +++++++++++++++++++ .../performance/aps-tsjs-prechange.json | 11 ++++--- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e35b59dc..2b1f09d59 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -247,6 +247,9 @@ jobs: - name: Build bundle run: npm run build + - name: Enforce bundle budgets + run: npm run check:bundle + - name: Typecheck full TSJS package run: npm run typecheck diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index bf40cf41c..d28d4c264 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -31,7 +31,7 @@ const metricsFile = 'tsjs-build-metrics-v1.json'; const releaseFile = 'tsjs-release-v1.json'; const fallbackFile = 'gpt-bootstrap-fallback.js'; -const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid']; +const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid', 'datadome']; function compress(bytes) { return { diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 1e42d7b78..ddde2d2d7 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -10,6 +10,7 @@ "build:prebid-external": "node build-prebid-external.mjs", "generate:aps-contract": "node ../../../scripts/generate-aps-renderer-contract.mjs", "check:aps-contract": "node ../../../scripts/generate-aps-renderer-contract.mjs --check", + "check:bundle": "node scripts/check-bundle-budgets.mjs", "dev": "vite build --watch", "test": "vitest run", "posttest": "npm run build && npm run test:release", diff --git a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs index 4194aad9a..752766679 100644 --- a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { RELEASE_SENTINEL, @@ -8,8 +11,36 @@ import { validateStampedRelease, } from '../../scripts/release-v1.mjs'; +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const libDirectory = path.resolve(testDirectory, '../..'); +const repositoryRoot = path.resolve(libDirectory, '../../..'); const bundle = (id, logical) => ({ id, bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`) }); +test('bundle metrics use the required five-module reference vector', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + + assert.deepEqual(metrics.sets.reference.files, [ + 'tsjs-core.js', + 'tsjs-creative.js', + 'tsjs-gpt.js', + 'tsjs-prebid.js', + 'tsjs-datadome.js', + ]); +}); + +test('bundle budgets are exposed through the package and enforced after the CI build', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(libDirectory, 'package.json'), 'utf8')); + const workflow = fs.readFileSync(path.join(repositoryRoot, '.github/workflows/test.yml'), 'utf8'); + const buildStep = workflow.indexOf('run: npm run build'); + const budgetStep = workflow.indexOf('run: npm run check:bundle'); + + assert.equal(packageJson.scripts['check:bundle'], 'node scripts/check-bundle-budgets.mjs'); + assert.notEqual(buildStep, -1); + assert.ok(budgetStep > buildStep, 'bundle budget check must run after the TSJS build'); +}); + test('release id changes with logical bytes and bundle order', () => { const base = [bundle('core', 'a'), bundle('gpt', 'b')]; assert.notEqual(computeReleaseId(base), computeReleaseId([bundle('core', 'changed'), base[1]])); diff --git a/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json index 12f8df903..282430f60 100644 --- a/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json +++ b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json @@ -33,12 +33,13 @@ "tsjs-core.js", "tsjs-creative.js", "tsjs-gpt.js", - "tsjs-prebid.js" + "tsjs-prebid.js", + "tsjs-datadome.js" ], - "rawBytes": 107265, - "gzipBytes": 33428, - "brotliBytes": 25236, - "sha256": "8b9a440310ad358c292864dfa2e088c895c59199c2518fe9a3044236e459d19d" + "rawBytes": 113756, + "gzipBytes": 35163, + "brotliBytes": 26051, + "sha256": "232b734406d9baec8f244d5ab1501535d0296d9f1e0d87e30fc9e30b6c96d204" }, "maximal": { "files": [ From 4b93ea443033ff59013ed0517227b363bd4d9699 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:18:40 -0700 Subject: [PATCH 292/494] docs: make resilience plan execution atomic --- ...8-04-aps-tsjs-resilience-implementation.md | 353 +++++++++++++----- 1 file changed, 262 insertions(+), 91 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 6865982d9..ac67389d4 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -528,7 +528,13 @@ Every task's regression suite therefore remains green in task order. npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts test/integrations/prebid/index.test.ts ``` -### Task 5: Serve the static renderer and live APS runner proxy with adapter parity +### Task 5: Serve the static renderer and live APS runner proxy in three green checkpoints + +Task 5 is an umbrella only. Execute and review three independent red-to-green +commits: 5A defines the common reserved-family and raw-proxy contract, 5B implements +and attests the four adapter transports, and 5C implements the static renderer plus +its fictional browser fixture. The combined inventory below is not authorization to +collapse those checkpoints or carry unverified behavior between them. **Files:** @@ -591,18 +597,19 @@ Every task's regression suite therefore remains green in task order. - Modify: `docs/guide/getting-started.md` - Modify: `docs/guide/testing.md` -- [ ] **Step 1: Write failing route and exact renderer-policy tests.** +#### Task 5A: Define and test the common reserved-route and raw-proxy contract + +- [ ] **Step A1: Write failing reserved-family and raw-proxy contract tests.** - Cover enabled `GET /integrations/aps/renderer/v1` and - `GET /integrations/aps/runner.js`; APS-disabled local `404 no-store`; local - negative `404 no-store` for `/integrations/aps/runner/v1.js`, unknown renderer - versions, and malformed family paths; `405` plus `Allow: GET`; and proof that no - reserved path reaches publisher auth, EC, or fallback. Assert renderer body bytes, - the exact ordered sandbox tokens, the exact CSP from spec §3.6, exact content type, - immutable cache policy, `nosniff`, and referrer policy. Assert the deliberate - absence of `X-Frame-Options` and CSP `frame-ancestors`. + Cover enabled `GET /integrations/aps/runner.js`; APS-disabled local + `404 no-store`; negative `/integrations/aps/runner/v1.js` and malformed family + paths; `405` plus `Allow: GET`; and proof that no reserved path reaches publisher + auth, EC, or fallback. At the common platform boundary, assert exact upstream + target/request evidence, the five-second dispatch-through-final-byte deadline, + cancellation, body cap, closed response grammar, and replacement headers. Static + renderer bytes and policy remain Task 5C. -- [ ] **Step 2: Run the new focused tests and prove they fail.** +- [ ] **Step A2: Run the new focused tests and prove they fail.** ```bash cargo test-fastly integrations::aps @@ -611,10 +618,9 @@ Every task's regression suite therefore remains green in task order. cargo test-spin --test routes ``` - Expected: the live runner route/raw proxy policy and exact renderer headers are not - yet implemented on every adapter. + Expected: the live runner route and raw-proxy policy are not implemented. -- [ ] **Step 3: Define the bounded raw-proxy platform contract.** +- [ ] **Step A3: Define the bounded raw-proxy platform contract.** Add a dedicated request/response policy in `platform/http.rs` and adapter implementations that: @@ -644,7 +650,21 @@ Every task's regression suite therefore remains green in task order. defaults. If a runtime cannot supply the required evidence or cancellation behavior, APS cannot be enabled there and the release is blocked. -- [ ] **Step 4: Write and pass the complete actual-adapter proxy corpus.** +- [ ] **Step A4: Make the common contract and core fakes green, then commit before adapter** + transport work. This checkpoint contains only reserved-family dispatch, + request/response evidence types, bounded policy, core validation, and test + support; it does not claim actual-runtime parity or renderer behavior. + + ```bash + cargo test --package trusted-server-core --target aarch64-apple-darwin integrations::aps + cargo fmt --all -- --check + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/mod.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/platform + git commit -m "Define the bounded APS runner proxy contract" + ``` + +#### Task 5B: Implement and attest all four actual adapter transports + +- [ ] **Step B1: Write and pass the complete actual-adapter proxy corpus.** Drive each real transport boundary—including Cloudflare and Spin wasm and full Fastly routes—against a controlled fictional upstream. Cover status other than @@ -697,7 +717,7 @@ Every task's regression suite therefore remains green in task order. and transport seam for the local Fastly simulator only; it is not an APS runner pin, and no APS runner version, digest, or body enters the repository. -- [ ] **Step 5: Implement the reserved dispatcher and live proxy response.** +- [ ] **Step B2: Implement the reserved dispatcher and live proxy response.** Register the family ahead of auth/EC/fallback through one explicit test-only registry constructor used by unit tests and the dedicated integration artifacts. @@ -712,7 +732,33 @@ Every task's regression suite therefore remains green in task order. no-referrer policy. Every upstream or validation failure returns a local empty `502 no-store`, with no vendor body or descriptor/capability data in logs. -- [ ] **Step 6: Implement and test the static renderer contract.** +- [ ] **Step B3: Run and commit adapter transport parity before adding the static renderer.** + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + git add crates/trusted-server-adapter-fastly crates/trusted-server-adapter-axum crates/trusted-server-adapter-cloudflare crates/trusted-server-adapter-spin crates/trusted-server-integration-tests scripts/integration-tests-aps-runner-proxy.sh scripts/integration-tests.sh .github/workflows/integration-tests.yml + git commit -m "Implement APS runner proxy parity" + ``` + +#### Task 5C: Implement the static renderer and fictional browser fixture + +- [ ] **Step C1: Write failing static-renderer route and policy tests.** Cover + `/integrations/aps/renderer/v1`, disabled and unknown-version local + `404 no-store`, malformed family paths, `405` plus `Allow: GET`, and proof the + route cannot reach publisher auth, EC, or fallback. Assert exact body bytes, + ordered sandbox tokens, CSP, content type, immutable cache policy, `nosniff`, + referrer policy, and deliberate absence of `X-Frame-Options` and CSP + `frame-ancestors`. + +- [ ] **Step C2: Implement and test the static renderer contract.** The renderer validates/clears the fragment nonce, accepts one exact source-bound parent port, validates the descriptor and kernel-captured publisher origin, and @@ -726,7 +772,7 @@ Every task's regression suite therefore remains green in task order. from document acceptance. Mutable APS callback correctness is an accepted external trust dependency, not a fact TS can derive from script load or body inspection. -- [ ] **Step 7: Add the hermetic fictional runner fixture.** +- [ ] **Step C3: Add the hermetic fictional runner fixture.** Author a minimal local fixture that implements only the documented event and queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor @@ -734,7 +780,7 @@ Every task's regression suite therefore remains green in task order. callback-silence, nested-iframe, and duplicate-callback tests. The fixture is not served as a production fallback and cannot be included in release bundles. -- [ ] **Step 8: Run the full route, transport, parity, and browser checks.** +- [ ] **Step C4: Run the full route, transport, parity, and browser checks.** ```bash cargo test-fastly @@ -751,47 +797,12 @@ Every task's regression suite therefore remains green in task order. tests/shared/aps-renderer.spec.ts --project=chromium ``` -- [ ] **Step 9: Commit the transport and renderer slice.** +- [ ] **Step C5: Commit only the static renderer and fictional browser fixture after C1-C4** + are green. Adapter transport files must already be clean from Task 5B. ```bash - git add \ - crates/trusted-server-core/src/integrations/aps.rs \ - crates/trusted-server-core/src/integrations/registry.rs \ - crates/trusted-server-core/src/platform/http.rs \ - crates/trusted-server-core/src/platform/test_support.rs \ - crates/trusted-server-core/src/platform/types.rs \ - crates/trusted-server-adapter-fastly/src/app.rs \ - crates/trusted-server-adapter-fastly/src/platform.rs \ - crates/trusted-server-adapter-fastly/Cargo.toml \ - crates/trusted-server-adapter-axum/src/app.rs \ - crates/trusted-server-adapter-axum/src/platform.rs \ - crates/trusted-server-adapter-axum/tests/routes.rs \ - crates/trusted-server-adapter-cloudflare/src/app.rs \ - crates/trusted-server-adapter-cloudflare/src/platform.rs \ - crates/trusted-server-adapter-cloudflare/Cargo.toml \ - crates/trusted-server-adapter-cloudflare/tests/routes.rs \ - crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml \ - crates/trusted-server-adapter-spin/src/app.rs \ - crates/trusted-server-adapter-spin/src/platform.rs \ - crates/trusted-server-adapter-spin/Cargo.toml \ - crates/trusted-server-adapter-spin/tests/routes.rs \ - crates/trusted-server-integration-tests/Cargo.toml \ - crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml \ - crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml \ - crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs \ - crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs \ - crates/trusted-server-integration-tests/tests/common/mod.rs \ - crates/trusted-server-integration-tests/tests/environments/spin.rs \ - crates/trusted-server-integration-tests/tests/environments/mod.rs \ - crates/trusted-server-integration-tests/tests/environments/cloudflare.rs \ - crates/trusted-server-integration-tests/tests/environments/fastly.rs \ - crates/trusted-server-integration-tests/tests/parity.rs \ - crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts \ - crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js \ - scripts/integration-tests-aps-runner-proxy.sh \ - scripts/integration-tests-browser.sh \ - .github/workflows/integration-tests.yml - git commit -m "feat(aps): proxy the live creative runner safely" + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js scripts/integration-tests-browser.sh docs/guide/error-reference.md docs/guide/getting-started.md docs/guide/testing.md + git commit -m "Implement the static APS renderer" ``` ### Phase 1 exit @@ -1767,7 +1778,16 @@ Every task's regression suite therefore remains green in task order. - [ ] **Step 3: Route APS and ADM winners to `RenderAttempt`/PUC bridge. Remove duplicate renderer** branches, slot expandos, local consumed-id maps, and independent refresh wrappers. -- [ ] **Step 4: Implement the owner-and-value targeting journal in `services/targeting.ts`.** +- [ ] **Step 4: Rebuild and unit-test the RCJ-GPT-04 collapsed-shell resize in the attempt-owned** + PUC success path. Resize only after the authenticated current attempt posts its + response, and only when the exact connected source iframe and its immediate + ordinary wrapper both remain collapsed to at most 1x1. Require finite positive + winning dimensions; reject anchors, unrelated frames, detached/replaced frames, + expanded dimensions, and fixed/sticky shells. Assert one guarded resize of only + those two nodes, plus inert replay, stale-attempt, navigation, and failure paths + in `test/integrations/gpt/ad_init.test.ts` and `test/services/render.test.ts`. + +- [ ] **Step 5: Implement the owner-and-value targeting journal in `services/targeting.ts`.** Keep one closure-private stack per physical GPT slot/key. Each TS write pushes a distinct frame containing its owner id, exact installed string, and predecessor value/owner—even when the string is unchanged. The GPT adapter observes the live @@ -1794,14 +1814,14 @@ Every task's regression suite therefore remains green in task order. reservation, compare-restores targeting, and settles. Prove a fast creative request always finds the store entry. -- [ ] **Step 5: Fold integration-specific script-guard mechanics onto the shared factory while** +- [ ] **Step 6: Fold integration-specific script-guard mechanics onto the shared factory while** keeping GPT configuration in its integration. Implement one runtime-owned `MutationObserver` per `NavigationSession`, 250 ms debounce, 5,000 ms monotonic window, one final boundary pass, the two-success cap, exact physical-object quarantine, and complete timer/candidate/reference disposal. Successful handoff cancels reconciliation and transfers cleanup ownership synchronously. -- [ ] **Step 6: Run the entire GPT suite, not only new files:** +- [ ] **Step 7: Run the entire GPT suite, not only new files:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt @@ -1881,7 +1901,33 @@ Every task's regression suite therefore remains green in task order. `prebid_selection_timeout`; navigation/auction abort clears the admitted set. No losing bid remains live for 15 minutes. -- [ ] **Step 5: Make the external artifact independently correct and pure.** Build exactly +- [ ] **Step 5: Rebuild and unit-test RCJ-PREBID-04 through one Prebid refresh policy over the** + GPT adapter. Literal, case-sensitive configured GAM-path suffixes remove only + eligible matches from the synthetic Prebid auction; missing, non-string, or + throwing `getAdUnitPath()` fails open. Clear stale TS/Prebid targeting from every + target, while the full original slot list and exact options continue to GPT. + Cover global, explicit, mixed, all-excluded, no-exclusion, and fail-open cases in + `test/integrations/prebid/index.test.ts`, `test/adapters/googletag.test.ts`, and + `test/integrations/gpt/ad_init.test.ts` before rebuilding the external artifact. + + Treat RCJ-PREBID-04 as its own named red-to-green checkpoint. Run the three focused + unit files before implementation and require the new cases to fail for refresh-list + or stale-targeting behavior; rerun them after implementation, rebuild the external + Prebid artifact, and rerun its purity/integration contract so the rebuild cannot + silently reintroduce refresh behavior: + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run \ + test/integrations/prebid/index.test.ts \ + test/adapters/googletag.test.ts \ + test/integrations/gpt/ad_init.test.ts + npm --prefix crates/trusted-server-js/lib run build:prebid-external + node --test \ + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ + crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs + ``` + +- [ ] **Step 6: Make the external artifact independently correct and pure.** Build exactly lockfile-resolved Prebid.js 10.26.0 with no TS auction, admission, render, targeting, or refresh behavior. The first wrapper statement arms an independent 5,000 ms queue-drain watchdog before stamp inspection or module factories. It @@ -1905,7 +1951,7 @@ Every task's regression suite therefore remains green in task order. the exact `pbjs` plus stamp identities, cover late valid replacement, and prove the external artifact contains no TS behavior or `window.__tsjs_*` handshake. -- [ ] **Step 6: Run:** +- [ ] **Step 7: Run:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps @@ -1915,6 +1961,11 @@ Every task's regression suite therefore remains green in task order. ### Task 18: Prepare creative, diagnostics, and remaining integration modules +Task 18 is an umbrella only. Execute the detailed 18A creative, 18B diagnostics, and +18C remaining-integration sections below as three independently reviewed +red-to-green commits. The shared inventory is not authorization to stage them as one +implementation change. + **Files:** - Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` @@ -2001,25 +2052,22 @@ Every task's regression suite therefore remains green in task order. - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` - Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` -- [ ] **Step 1: Add a maximal-bundle failing smoke test that loads core followed by every** - server-declared integration in manifest order and asserts one runtime, no unknown - integration id, no duplicate activation, exact reverse-order disposal, and no - leaked timer/listener/wrapper/observer after disposal. Run every module alone - and in the maximal manifest with missing globals, readiness/timeouts, malformed - config/consent/storage, matcher false positives, callback throws, startup - failure, and cross-integration isolation. +#### Task 18A: Rebuild creative as one independently green integration module -- [ ] **Step 2: Convert every remaining capability into a thin integration module.** Each +- [ ] **Step A1: Add the failing creative-only composition and lifecycle corpus.** Cover + boot validation, guard enablement combinations, automatic scans, wrapper and + observer ownership, hostile callbacks, startup rollback, disposal, and every + existing click/image/iframe/proxy-sign behavior. Run creative alone and inside + a manifest composition without modifying any other integration. + +- [ ] **Step A2: Convert only creative into a thin integration module.** Its `_registerIntegration({id,release,prepare})` call is pure registration; - `prepare(ctx)` is inert and Promise-returning; the returned `activate(ctx)` is - synchronous, registers a disposer before each reversible mutation, and uses at - most one staged `afterCommit` callback for irreversible work. Exercise all - modules through the same manifest-ordered test composition. Preserve existing - feature behavior and integration-owned matchers/configuration; shared helpers - must not broaden matching, reorder startup, stack interception, or retain work - after disposal. Do not change shipped entry-point side effects until Task 19. - -- [ ] **Step 3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate + `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, + pre-registers disposal before every reversible mutation, and contributes at + most one `afterCommit` callback. Keep shipped entry-point side effects unchanged + until Task 19. + +- [ ] **Step A3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate the complete plain-object shape, defaults, disabled/manifest mismatch, unknown keys, accessors, prototypes, and literals before preparation. Activation installs the click guard when `clickGuard` is true and dynamic image/iframe guards when @@ -2037,7 +2085,19 @@ Every task's regression suite therefore remains green in task order. rejection of credentials, malformed values, and non-network schemes. Delete the mutable/install creative globals only in Task 22. -- [ ] **Step 4: Move render tracing to the kernel diagnostics bus and exact public surface.** +- [ ] **Step A4: Run and commit the creative slice before diagnostics or other modules.** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + git add crates/trusted-server-js/lib/src/integrations/creative crates/trusted-server-js/lib/test/integrations/creative crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts + git commit -m "Prepare the creative integration module" + ``` + +#### Task 18B: Rebuild diagnostics transport, producers, and consumers + +- [ ] **Step B1: Move render tracing to the kernel diagnostics bus and exact public surface.** `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot navigation registry; prune on disposal. Keep document-runtime history at 200, @@ -2053,7 +2113,7 @@ Every task's regression suite therefore remains green in task order. registration-during-dispatch, callback throw isolation, and 199/200/201 overflow. Emit no `CustomEvent`, mutable trace global, or compatibility alias. -- [ ] **Step 5: Preserve GPT diagnostics through the adapter event stream.** Validate exact +- [ ] **Step B2: Preserve GPT diagnostics through the adapter event stream.** Validate exact `DiagnosticsBootV1` plus manifest activation before any listener/buffer exists. When active, core owns the six documented GPT observations before TS requests, buffers 512 raw facts until module activation, then replays and releases the @@ -2071,7 +2131,40 @@ Every task's regression suite therefore remains green in task order. storage, upload, old flag, runtime expando, or `tsjs.gptDiagnostics` alias remains after Task 22. -- [ ] **Step 6: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome +- [ ] **Step B3: Rebuild and unit-test the server-owned `ts_console` browser-session mechanics.** + On eligible GET document navigations, accept exactly one case-sensitive + `ts_console=1|true` enable directive or `0|false` disable directive; + duplicate, conflicting, empty, or unknown values fail closed for that response. + Strip every reserved pair before publisher/origin/cookie/auction handling, + preserve all unrelated path/query/fragment data, and set or clear only the + host-only `Secure`, `HttpOnly`, `SameSite=Lax` session cookie. Assert same-origin + tab/session behavior, disabled-by-default behavior, and that frozen + `DiagnosticsBootV1.gpt.active` is the only browser-visible activation result. + +- [ ] **Step B4: Wire every diagnostics producer explicitly after its correctness commit.** + `RenderAttempt` publishes immutable render observations only after terminal or + accepted-artifact state commits; the sole GPT adapter publishes its six raw + facts only after adapter bookkeeping commits. Both use the kernel-owned bus, + never call public subscribers inline, and cannot delay, reject, retry, or mutate + rendering/GPT behavior. Test inactive zero-effects, producer throw isolation, + event ordering, enrichment replacement, buffer release, navigation disposal, + and absence of `CustomEvent`, mutable globals, or a second GPT listener set. + +- [ ] **Step B5: Run and commit diagnostics transport, producer, and consumer wiring as one** + independently green slice. + + ```bash + cargo test --package trusted-server-core --target aarch64-apple-darwin trace_cookie + npm --prefix crates/trusted-server-js/lib test -- --run test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + git add crates/trusted-server-core/src/trace_cookie.rs crates/trusted-server-core/src/integrations/gpt_diagnostics.rs crates/trusted-server-js/lib/src/core/trace.ts crates/trusted-server-js/lib/src/services crates/trusted-server-js/lib/src/adapters/googletag.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics crates/trusted-server-js/lib/test/core/trace.test.ts crates/trusted-server-js/lib/test/services/render.test.ts crates/trusted-server-js/lib/test/adapters/googletag.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics + git commit -m "Rebuild bounded runtime diagnostics" + ``` + +#### Task 18C: Migrate the remaining integrations and maximal manifest + +- [ ] **Step C1: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome script/preload path rewriting; Didomi absolute SDK path without config clobber; GTM script/preload and GA beacon/fetch rewriting; Lockr bounded readiness and API host; Osano USP/GPP/TCF marker ownership and lifecycle; Permutive bounded @@ -2084,14 +2177,29 @@ Every task's regression suite therefore remains green in task order. provider survives failed activation or module/runtime disposal, and SPA navigation does not register a duplicate. -- [ ] **Step 7: Generate and test the prospective manifest member list/order from the exact** +- [ ] **Step C2: Convert only the remaining integrations into thin modules.** Each + `_registerIntegration({id,release,prepare})` call is pure registration; + `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, + pre-registers disposal before reversible mutation, and contributes at most one + `afterCommit`. Shared helpers must preserve each integration's exact matcher, + startup order, failure isolation, and disposal semantics. + +- [ ] **Step C3: Add the maximal-bundle failing smoke test.** Load core followed by every + server-declared integration in manifest order and assert one runtime, no unknown + id, no duplicate activation, exact reverse-order disposal, and no leaked timer, + listener, wrapper, observer, context provider, or queued continuation. Run each + module alone and in the maximal manifest with missing globals, timeout, malformed + config/consent/storage, matcher false positives, callback throws, startup + failure, and cross-integration isolation. + +- [ ] **Step C4: Generate and test the prospective manifest member list/order from the exact** enabled bundle list. Embed the same release id in core and every integration IIFE. Add failures for integration before core, unknown/missing/duplicate member, malformed/unsorted/oversized manifest, wrong release, preparation or activation failure, duplicate `afterCommit`, and the 16-member/10-second transaction limits. Production manifest emission starts only in Task 19. -- [ ] **Step 8: Run:** +- [ ] **Step C5: Run the complete remaining-integration and maximal-manifest gate:** ```bash npm --prefix crates/trusted-server-js/lib test @@ -2101,6 +2209,16 @@ Every task's regression suite therefore remains green in task order. cargo test-fastly publisher ``` +- [ ] **Step C6: Commit the remaining integrations only after C1-C5 are green.** Stage the + remaining integration directories, their shared helpers/tests, composition, + build manifest, and exact Rust config emitters; do not fold creative or + diagnostics changes from Tasks 18A/18B into this commit. + + ```bash + git add crates/trusted-server-js/lib/src/integrations crates/trusted-server-js/lib/test/integrations crates/trusted-server-js/lib/src/shared crates/trusted-server-js/lib/test/shared crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-core/src/integrations + git commit -m "Prepare the remaining integration modules" + ``` + ### Task 19: Complete lifecycle behavior and perform the coordinated production switch **Files:** @@ -2203,7 +2321,47 @@ Every task's regression suite therefore remains green in task order. - [ ] **Step 4: Test already-loaded-page limits honestly: configuration changes reach a page only** through an existing response path; do not add polling, push, or event ingestion. -- [ ] **Step 5: Atomically activate the new production surface in one task and one commit:** +- [ ] **Step 5: Complete the pre-switch checklist with no production-wiring changes staged.** + The atomic switch is allowed to flip wiring only after every behavior suite + below is already green against the test-only composition and prospective + routes/artifacts: + - render attempt, direct APS, direct ADM, bounded cache, PUC claim/channel, artifact + store, reservations, slots, targeting, projections, auction batching, context, + navigation sessions, runtime transaction, queue handoff, and integration registry; + - GPT including RCJ-GPT-04, Prebid including RCJ-PREBID-04 and the rebuilt pure + 10.26.0 artifact, APS, creative, render trace, GPT diagnostics/`ts_console`, and + every remaining integration alone and in the maximal manifest; + - generated release/fallback/absence contracts, architecture/lint/typecheck, all + Rust projection/config/route tests, adapter parity, and the four actual-adapter + runner-proxy corpora; and + - old-surface rejection plus new-surface fixture tests, proving the cutover commit + contains no new behavior implementation or test repair. + + Install the real performance marks before this checklist closes. Execute + `performance.mark('tsjs:bids-script')` in the actual server-emitted bids/projection + boot script, and execute `performance.mark('tsjs:first-display')` exactly once at + the first authoritative GPT display call in the real adapter path. The browser + performance fixture must measure those marks with + `performance.measure('tsjs:boot-to-first-display', 'tsjs:bids-script', 'tsjs:first-display')`; + `window.__tsjsPerf` remains baseline-capture scaffolding and cannot satisfy the + post-switch gate. + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run \ + test/services test/kernel test/adapters test/core + npm --prefix crates/trusted-server-js/lib test -- --run \ + test/integrations test/composition test/build + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ``` + +- [ ] **Step 6: Atomically activate the new production surface in one task and one commit:** - `/auction` emits/parses only the exact decision-set/tagged-source wire, and initial HTML/page-bids emit only `tsjs.boot.auctionProjection`; - the immutable initial projection seeds the first `NavigationSession`; every SPA @@ -2260,7 +2418,11 @@ Every task's regression suite therefore remains green in task order. services; and - render trace and GPT diagnostics commit only after correctness transitions and expose their exact bounded asynchronous frozen APIs. Creative guards auto-install - from frozen boot configuration and both-false guards have zero DOM side effects. + from frozen boot configuration and both-false guards have zero DOM side effects; + and + - the real boot/render path records the named `tsjs:bids-script` and + `tsjs:first-display` performance marks at their authoritative transitions; the + temporary `__tsjsPerf` baseline shim is not carried into the switched runtime. Before enabling the Fastly production route, run the unchanged stall/slow-drip deadline cases through a non-production Fastly Compute service and a controlled @@ -2274,7 +2436,7 @@ Every task's regression suite therefore remains green in task order. manifest, or shape autodetection. The temporarily unused server routes and old declarations are deleted in Task 22 before release. -- [ ] **Step 6: Run:** +- [ ] **Step 7: Run:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/services test/core test/integrations/gpt @@ -2354,8 +2516,13 @@ Every task's regression suite therefore remains green in task order. 9,999/10,000/10,001 ms boundaries, duplicate `afterCommit`, 15/16 member capacity, late continuation after fallback, publisher work during startup, exact same-task rollback, full/fallback `TsjsApi` own surfaces, malformed boot, actual-Array queue - swap/retained references/native mutators/nested pushes/callback throws, and missing - main bundle after server projection; + swap/retained references/native mutators/nested pushes/callback throws, and + missing main bundle after server projection. For every fallback commit, + instrument the real browser surfaces and assert that no second runtime, + GPT/Prebid/message listener, `MessagePort`, interval/timeout, request, script, + wrapper, observer, guard, or iframe survives; dispatch late messages, timer + boundaries, and bundles afterward and prove none can revive rendering or allocate + replacement state; - navigation/projection/API: immutable initial boot versus SPA-owned replacement, stale/duplicate/malformed page-bids, exact grammar/count/UTF-8 and 8 MiB all-winner reduction, 255/256/257 combined server/programmatic slots, transactional @@ -2604,7 +2771,11 @@ Every task's regression suite therefore remains green in task order. - [ ] **Step 2: On the pinned Chromium/CI-machine/fixture, measure boot-to-first-display p90 after** five warmups and 50 samples and require ≤1.10× the Task 0 baseline. Do not rerun - selectively to turn a failed sample into a pass. + selectively to turn a failed sample into a pass. The post-switch sample reads + the real `tsjs:bids-script` and `tsjs:first-display` performance marks and the + `tsjs:boot-to-first-display` measure installed in Task 19; fail if any sample + falls back to the pre-change `window.__tsjsPerf` placeholder or lacks either + mark. - [ ] **Step 3: Through Chromium CDP, collect garbage then record retained heap after boot, first** render, refresh, and SPA navigation; gate each checkpoint at ≤1.10×. Firefox and From e059f998ef251d319820923017a52a9a6a3a8276 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:20:17 -0700 Subject: [PATCH 293/494] docs: clarify resilience contract guardrails --- .../trusted-server-core/src/auction/types.rs | 3 +++ .../generated/aps_renderer_validator_v1.js | 2 +- .../lib/eslint-rules/no-adtech-globals.js | 4 ++++ .../generated/renderer_validator_v1.ts | 2 +- .../test/fixtures/aps-renderer-v1.schema.json | 1 + ...8-04-aps-tsjs-resilience-implementation.md | 20 +++++++++++++++---- ...s-render-fix-and-tsjs-resilience-design.md | 13 ++++++++---- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 9e03db98f..7be13d7d5 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -259,6 +259,9 @@ pub enum AuctionDropReason { impl AuctionDropReason { /// Return the exact existing debug/projection literal. + /// + /// This hand-written mapping also drives [`Ord`] so serialized-map output stays + /// alphabetically stable even when declaration order changes. #[must_use] pub const fn as_str(self) -> &'static str { match self { diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js index a81408244..5c8a161f1 100644 --- a/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js @@ -1,5 +1,5 @@ // @generated by scripts/generate-aps-renderer-contract.mjs -// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027 // corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 var DESCRIPTOR_KEYS = ["aaxResponse","accountId","bidId","creativeUrl","height","tagType","type","version","width"]; var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ["aaxResponse","accountId","bidId","creativeId","creativeUrl","height","tagType","type","version","width"]; diff --git a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js index 3cf962a9e..78aff60b6 100644 --- a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -1,6 +1,10 @@ const ADTECH_GLOBALS = new Set(['googletag', 'pbjs']); const GLOBAL_ROOTS = new Set(['globalThis', 'self', 'window']); +// Known blind spots include computed composition (`globalThis['goog' + 'letag']`) +// and function-returned roots (`getWin().googletag`); adapter boundaries and +// restricted imports remain defense in depth. + export const LEGACY_ADTECH_GLOBAL_ALLOWLIST = Object.freeze([ 'src/integrations/gpt/index.ts', 'src/integrations/gpt_diagnostics/observer.ts', diff --git a/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts index 33d714185..dec409ae6 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_v1.ts @@ -1,5 +1,5 @@ // @generated by scripts/generate-aps-renderer-contract.mjs -// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// schema-sha256: 3f82e9c8d57719c29810a0ed181f4fe2779919c65605ae0cd7c61bd6d865b027 // corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 /* eslint-disable */ export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | 'invalid_dimensions' | 'dimensions_out_of_range'; diff --git a/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json index ef605725a..4fe6c0445 100644 --- a/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json +++ b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json @@ -1,6 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://iabtechlab.com/trusted-server/aps-renderer-v1.schema.json", + "$comment": "The x-* semantic markers are documentation only; scripts/generate-aps-renderer-contract.mjs hard-codes these checks and does not read marker values, so editing a marker does not change enforcement.", "title": "Trusted Server APS renderer descriptor version 1", "type": "object", "additionalProperties": false, diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index ac67389d4..95437e703 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -220,6 +220,12 @@ Every task's regression suite therefore remains green in task order. `aps-tsjs-prechange.json`; later tasks may compare against it but must not regenerate it from the completed implementation. + Define the sets exactly as minimal `[core]`, reference + `[core, creative, gpt, prebid, datadome]`, and maximal core plus every built + integration. Expose the comparator as `npm run check:bundle` and run it in the + TypeScript CI job immediately after `npm run build`; a generated metrics file that + is not consumed by CI is not a gate. + Extend `scripts/integration-tests-browser.sh` with `TS_BROWSER_FRAMEWORKS=nextjs` and use `npm --prefix ... exec -- playwright` for argument-safe invocation. The script remains the clean-checkout fixture builder: @@ -622,8 +628,8 @@ collapse those checkpoints or carry unverified behavior between them. - [ ] **Step A3: Define the bounded raw-proxy platform contract.** - Add a dedicated request/response policy in `platform/http.rs` and adapter - implementations that: + Add a dedicated request/response policy in `platform/http.rs` and core test-support + contract that requires adapter implementations to: - sends only credential-free `GET` to the compile-time fixed URL `https://client.aps.amazon-adsystem.com/prebid-creative.js` with `Accept-Encoding: identity`, no forwarded browser/publisher headers, no referrer, @@ -638,7 +644,7 @@ collapse those checkpoints or carry unverified behavior between them. generation-inert late continuations; and - uses the common `APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` cap. - Cloudflare must preserve `web_sys::Request.method()` before workers-rs conversion + Task 5B's Cloudflare implementation must preserve `web_sys::Request.method()` before workers-rs conversion and restore it at the reserved pre-router boundary because workers-rs maps extension methods such as `PROPFIND` to `GET`. It must also inspect the initial Workers headers before its generic adapter strips encoding/length; concatenated duplicates remain @@ -717,7 +723,8 @@ collapse those checkpoints or carry unverified behavior between them. and transport seam for the local Fastly simulator only; it is not an APS runner pin, and no APS runner version, digest, or body enters the repository. -- [ ] **Step B2: Implement the reserved dispatcher and live proxy response.** +- [ ] **Step B2: Implement each actual adapter transport, the reserved dispatcher, and the live** + **proxy response.** Register the family ahead of auth/EC/fallback through one explicit test-only registry constructor used by unit tests and the dedicated integration artifacts. @@ -2337,6 +2344,11 @@ implementation change. - old-surface rejection plus new-surface fixture tests, proving the cutover commit contains no new behavior implementation or test repair. + Keep the ±5% bundle-size gate wired and visible. Intermediate old-plus-new bundle + growth may remain recorded as a failing pre-switch check, but it must never be + rebased into the immutable pre-change artifact; the gate must be green after the + atomic switch and Task 22 legacy deletion, before release readiness. + Install the real performance marks before this checklist closes. Execute `performance.mark('tsjs:bids-script')` in the actual server-emitted bids/projection boot script, and execute `performance.mark('tsjs:first-display')` exactly once at diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index ec4767208..51751f2f7 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -2503,7 +2503,11 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' + | 'inline' + | 'gam' + | 'debug-adm' + | 'pbs-cache' + | 'prebid' interface RenderTraceRecord { readonly slotId: string @@ -2778,9 +2782,10 @@ After that upgrade, the lockfile compiler is the authority. CI runs a checked-in `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, and `useUnknownInCatchVariables`. Production bundles contain no dynamic imports. -Before implementation, CI records deterministic gzip/Brotli baselines for minimal, -reference, and maximal integration sets; each may grow at most 5% unless separately -approved. Boot-to-first-display p90 uses a pinned Chromium version, CI runner class, +Before implementation, CI records deterministic gzip/Brotli baselines for the +minimal `[core]`, reference `[core, creative, gpt, prebid, datadome]`, and maximal +all-built-integration sets; each may grow at most 5% unless separately approved. +Boot-to-first-display p90 uses a pinned Chromium version, CI runner class, fixture, warmup count, and sample count and must stay within 10% of that pre-change baseline. Retained heap uses Chromium CDP only, with forced-GC checkpoints after boot, first render, refresh, and SPA navigation, and the same 10% limit. Correctness From 1c68d9f5650c46c8c662dc86089179d13f82683b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:45:34 -0700 Subject: [PATCH 294/494] Implement bounded cache rendering --- .../lib/src/composition/browser.ts | 19 + .../lib/src/services/render.ts | 639 +++++++++++++++++- .../lib/test/composition/browser.test.ts | 1 + .../lib/test/services/render.test.ts | 589 ++++++++++++++++ 4 files changed, 1238 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3dff7037a..e488e8093 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -39,6 +39,7 @@ import { import { createReservationService, type ReservationService } from '../services/reservations'; import { createRendererNonceRegistry, + renderDirectCacheAttempt, renderDirectAdmAttempt, type RenderAttempt, type RendererNonceRegistry, @@ -61,6 +62,7 @@ export interface BrowserServices { readonly rendererNonces: RendererNonceRegistry; readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly renderDirectAps: (attempt: RenderAttempt, container: HTMLElement) => boolean; + readonly renderDirectCache: (attempt: RenderAttempt, container: HTMLElement) => boolean; readonly slots: SlotService; readonly targeting: TargetingService; } @@ -218,6 +220,7 @@ export function createTestBrowserRuntimeComposition( }); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; + const fetchCache = globalThis.fetch; const renderDirectAdm = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectAdmAttempt({ @@ -230,6 +233,21 @@ export function createTestBrowserRuntimeComposition( return false; } }; + const renderDirectCache = (attempt: RenderAttempt, container: HTMLElement): boolean => { + if (!cachePolicy || typeof fetchCache !== 'function') return false; + try { + return renderDirectCacheAttempt({ + attempt, + cachePolicy, + container, + fetcher: (input, init) => fetchCache(input, init), + prepareIframe: prepareAdmIframe, + publisherOrigin, + }); + } catch { + return false; + } + }; const renderDirectAps = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectApsAttempt({ @@ -248,6 +266,7 @@ export function createTestBrowserRuntimeComposition( rendererNonces, renderDirectAdm, renderDirectAps, + renderDirectCache, slots: slotService, targeting: targetingService, }); diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index e1ad3046d..d83e81d98 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -14,6 +14,9 @@ const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; const RENDERER_NONCE = /^n1_[A-Za-z0-9_-]{22}$/; const MAX_RENDERER_NONCES = 256; const MAX_RENDERER_NONCE_DRAWS = 8; +const MAX_CACHE_BODY_BYTES = 512 * 1024; +const MAX_CACHE_URL_BYTES = 4096; +const CACHE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const reflectApplyIntrinsic = Reflect.apply; const directAdmDocument = typeof document === 'undefined' ? undefined : document; const directAdmOwnerDocumentGetter = @@ -21,6 +24,7 @@ const directAdmOwnerDocumentGetter = ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; const objectFreezeIntrinsic = Object.freeze; +const objectToStringIntrinsic = Object.prototype.toString; const arrayIncludesIntrinsic = Array.prototype.includes; const arrayPushIntrinsic = Array.prototype.push; const arraySliceIntrinsic = Array.prototype.slice; @@ -59,6 +63,10 @@ const weakSetAddIntrinsic = WeakSet.prototype.add; const weakSetHasIntrinsic = WeakSet.prototype.has; const weakSetDeleteIntrinsic = WeakSet.prototype.delete; const promiseThenIntrinsic = Promise.prototype.then; +const stringIndexOfIntrinsic = String.prototype.indexOf; +const stringSliceIntrinsic = String.prototype.slice; +const stringIntrinsic = String; +const jsonParseIntrinsic = JSON.parse; const artifactDisposals = new WeakMap(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); @@ -72,6 +80,14 @@ function arrayPush(array: Value[], value: Value): number { return Reflect.apply(arrayPushIntrinsic, array, [value]) as number; } +function isUint8Array(value: unknown): value is Uint8Array { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + reflectApplyIntrinsic(objectToStringIntrinsic, value, []) === '[object Uint8Array]' + ); +} + function arraySlice(array: Value[]): Value[] { return Reflect.apply(arraySliceIntrinsic, array, [0]) as Value[]; } @@ -321,6 +337,11 @@ export const RENDER_STATE_DEADLINES: Readonly< waiting_for_adm: frozen({ milliseconds: 5_000, reason: 'adm_document_no_load' }), }); +const CACHE_FETCH_DEADLINE = frozen({ + milliseconds: 5_000, + reason: 'cache_network_error' as const, +}); + export interface RenderAttemptOptions { readonly owner: RenderAttemptScope; readonly artifacts: CommittedArtifactStore; @@ -356,6 +377,8 @@ export interface RenderAttempt { readonly beginGamClaim: () => boolean; readonly ownerClaimed: () => boolean; readonly ownerRegistered: () => boolean; + readonly beginCacheFetch: () => boolean; + readonly cacheFetchCompleted: () => boolean; readonly beginDirect: () => boolean; readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; @@ -375,6 +398,23 @@ export interface DirectAdmAttemptOptions { readonly publisherOrigin: string; } +interface CacheFetchReader { + readonly cancel?: () => Promise | unknown; + readonly read: () => Promise>; + readonly releaseLock?: () => void; +} + +interface CacheFetchResponse { + readonly body: Readonly<{ getReader: () => CacheFetchReader }> | null; + readonly ok: boolean; + readonly type?: Response['type']; +} + +export interface DirectCacheAttemptOptions extends DirectAdmAttemptOptions { + readonly cachePolicy: Readonly<{ version: 1; baseUrl: string }>; + readonly fetcher: (input: string, init: RequestInit) => Promise; +} + export interface DirectAdmIframeHandle { readonly frame: HTMLIFrameElement; append(): boolean; @@ -957,6 +997,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let pendingArtifact: CommittedRenderArtifact | undefined; let admittedRenderSource: ReservationRenderSource | undefined; let admittedWinnerContext: WinnerContext | undefined; + let cacheFetchStarted = false; let deadlineHandle: unknown; let deadlineState: RenderAttemptActiveState | undefined; let settlingInternally = false; @@ -1198,8 +1239,11 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ? settle(frozen({ outcome: 'failed', reason }), true) : false; - const armDeadline = (entered: RenderAttemptActiveState): void => { - const deadline = RENDER_STATE_DEADLINES[entered]; + const armDeadline = ( + entered: RenderAttemptActiveState, + explicitDeadline?: RenderDeadline + ): void => { + const deadline = explicitDeadline ?? RENDER_STATE_DEADLINES[entered]; if (!deadline) return; deadlineState = entered; try { @@ -1229,7 +1273,8 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp const enter = ( allowed: readonly RenderAttemptActiveState[], - next: RenderAttemptActiveState + next: RenderAttemptActiveState, + explicitDeadline?: RenderDeadline ): boolean => { if (outcome !== undefined || !ownerIsCurrent() || !allowed.includes(state as never)) { return false; @@ -1237,7 +1282,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp state = next; arrayPush(history, next); clearDeadline(); - if (outcome === undefined) armDeadline(next); + if (outcome === undefined) armDeadline(next, explicitDeadline); return true; }; @@ -1269,6 +1314,29 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp return false; }; + const beginCacheFetch = (): boolean => { + if ( + cacheFetchStarted || + outcome !== undefined || + admittedRenderSource?.type !== 'cache' || + admittedWinnerContext === undefined || + !ownerIsCurrent() + ) { + return false; + } + if (state === 'created') { + cacheFetchStarted = true; + return enter(['created'], 'rendering_direct', CACHE_FETCH_DEADLINE); + } + if (state !== 'waiting_for_insertion') return false; + cacheFetchStarted = true; + clearDeadline(); + if (outcome === undefined && state === 'waiting_for_insertion') { + armDeadline('waiting_for_insertion', CACHE_FETCH_DEADLINE); + } + return outcome === undefined && state === 'waiting_for_insertion'; + }; + const lifecycle: RenderAttempt = { id, slot, @@ -1292,8 +1360,24 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ? enter(['waiting_for_gam_and_claim'], 'waiting_for_owner') : false, ownerRegistered: () => enter(['waiting_for_owner'], 'waiting_for_insertion'), + beginCacheFetch, + cacheFetchCompleted: () => { + if ( + admittedRenderSource?.type !== 'cache' || + !admittedWinnerContext || + outcome !== undefined || + (state !== 'rendering_direct' && state !== 'waiting_for_insertion') || + deadlineState !== state || + !ownerIsCurrent() + ) { + return false; + } + clearDeadline(); + return true; + }, beginDirect: () => - admittedRenderSource && admittedWinnerContext + (admittedRenderSource?.type === 'aps' || admittedRenderSource?.type === 'adm') && + admittedWinnerContext ? enter(['created'], 'rendering_direct') : false, beginApsDocument: (artifact) => @@ -1435,6 +1519,337 @@ type DirectAdmSource = Readonly<{ width: number; }>; +type DirectCacheSource = Readonly<{ + cacheId: string; + fetchUrl: string; + height: number; + type: 'cache'; + version: 1; + width: number; +}>; + +type CacheBodyResult = + | Readonly<{ ok: true; text: string }> + | Readonly<{ ok: false; reason: 'cache_network_error' | 'cache_invalid_response' }>; + +function exactFrozenDataRecord( + value: unknown, + expectedNames: readonly string[] +): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + !Object.isFrozen(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(value).sort(); + if (names.length !== expectedNames.length) return undefined; + const fields = Object.create(null) as Record; + for (let index = 0; index < expectedNames.length; index += 1) { + const name = expectedNames[index]; + if (!name || names[index] !== name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if ( + !descriptor || + !('value' in descriptor) || + descriptor.enumerable !== true || + descriptor.configurable !== false || + descriptor.writable !== false + ) { + return undefined; + } + fields[name] = descriptor.value; + } + return fields; + } catch { + return undefined; + } +} + +function readCachePolicyBase(value: unknown): URL | undefined { + try { + const fields = exactFrozenDataRecord(value, ['baseUrl', 'version']); + const baseUrl = fields?.['baseUrl']; + if ( + !fields || + fields['version'] !== 1 || + typeof baseUrl !== 'string' || + baseUrl.length === 0 || + new TextEncoder().encode(baseUrl).byteLength > MAX_CACHE_URL_BYTES + ) { + return undefined; + } + for (let index = 0; index < baseUrl.length; index += 1) { + const code = baseUrl.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return undefined; + if (code >= 0xd800 && code <= 0xdbff) { + const next = baseUrl.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + } + const base = new URL(baseUrl); + if ( + base.protocol !== 'https:' || + base.hostname === '' || + base.username !== '' || + base.password !== '' || + base.search !== '' || + base.hash !== '' || + base.pathname === '/' + ) { + return undefined; + } + return base; + } catch { + return undefined; + } +} + +function readDirectCacheSource( + value: unknown, + cachePolicy: unknown +): DirectCacheSource | undefined { + try { + const base = readCachePolicyBase(cachePolicy); + if (!base) return undefined; + const fields = exactFrozenDataRecord(value, [ + 'cacheId', + 'fetchUrl', + 'height', + 'type', + 'version', + 'width', + ]); + if ( + !fields || + fields['type'] !== 'cache' || + fields['version'] !== 1 || + typeof fields['cacheId'] !== 'string' || + !CACHE_ID.test(fields['cacheId']) || + typeof fields['fetchUrl'] !== 'string' || + fields['fetchUrl'].length === 0 || + new TextEncoder().encode(fields['fetchUrl']).byteLength > MAX_CACHE_URL_BYTES || + typeof fields['width'] !== 'number' || + !Number.isInteger(fields['width']) || + fields['width'] < 1 || + fields['width'] > 4096 || + typeof fields['height'] !== 'number' || + !Number.isInteger(fields['height']) || + fields['height'] < 1 || + fields['height'] > 4096 + ) { + return undefined; + } + const sourceFetchUrl = fields['fetchUrl'] as string; + for (let index = 0; index < sourceFetchUrl.length; index += 1) { + const code = sourceFetchUrl.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return undefined; + if (code >= 0xd800 && code <= 0xdbff) { + const next = sourceFetchUrl.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + } + const fetchUrl = new URL(sourceFetchUrl); + const expected = new URL(base.href); + expected.search = `?uuid=${encodeURIComponent(fields['cacheId'])}`; + if ( + fetchUrl.protocol !== 'https:' || + fetchUrl.username !== '' || + fetchUrl.password !== '' || + fetchUrl.hash !== '' || + fetchUrl.origin !== base.origin || + fetchUrl.port !== base.port || + fetchUrl.pathname !== base.pathname || + [...fetchUrl.searchParams.keys()].length !== 1 || + fetchUrl.searchParams.get('uuid') !== fields['cacheId'] || + fetchUrl.search !== `?uuid=${encodeURIComponent(fields['cacheId'])}` || + fetchUrl.href !== fields['fetchUrl'] || + fetchUrl.href !== expected.href + ) { + return undefined; + } + return value as DirectCacheSource; + } catch { + return undefined; + } +} + +function readSelectedCpm(value: unknown): number | undefined { + const fields = exactFrozenDataRecord(value, ['selectedCpm']); + const selectedCpm = fields?.['selectedCpm']; + return typeof selectedCpm === 'number' && Number.isFinite(selectedCpm) && selectedCpm >= 0 + ? selectedCpm + : undefined; +} + +function expandAuctionPrice(adm: string, selectedCpm: number): string { + const token = '${AUCTION_PRICE}'; + const replacement = stringIntrinsic(selectedCpm); + let cursor = 0; + let output = ''; + while (true) { + const next = reflectApplyIntrinsic(stringIndexOfIntrinsic, adm, [token, cursor]) as number; + if (next < 0) { + return output + (reflectApplyIntrinsic(stringSliceIntrinsic, adm, [cursor]) as string); + } + output += + (reflectApplyIntrinsic(stringSliceIntrinsic, adm, [cursor, next]) as string) + replacement; + cursor = next + token.length; + } +} + +function parseCacheAdm( + text: string, + source: DirectCacheSource, + selectedCpm: number +): DirectAdmSource | undefined { + try { + const value = reflectApplyIntrinsic(jsonParseIntrinsic, JSON, [text]) as unknown; + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const record = value as Record; + const names = Object.getOwnPropertyNames(record); + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (!name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(record, name); + if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) { + return undefined; + } + } + const hasOwn = (name: string): boolean => Object.prototype.hasOwnProperty.call(record, name); + if (hasOwn('width') || hasOwn('height')) return undefined; + const admDescriptor = Object.getOwnPropertyDescriptor(record, 'adm'); + if ( + !admDescriptor || + !('value' in admDescriptor) || + typeof admDescriptor.value !== 'string' || + admDescriptor.value.trim().length === 0 || + new TextEncoder().encode(admDescriptor.value).byteLength > MAX_CACHE_BODY_BYTES + ) { + return undefined; + } + const hasWidth = hasOwn('w'); + const hasHeight = hasOwn('h'); + if (hasWidth !== hasHeight) return undefined; + if ( + hasWidth && + (typeof record['w'] !== 'number' || + !Number.isInteger(record['w']) || + record['w'] < 1 || + record['w'] > 4096 || + record['w'] !== source.width || + typeof record['h'] !== 'number' || + !Number.isInteger(record['h']) || + record['h'] < 1 || + record['h'] > 4096 || + record['h'] !== source.height) + ) { + return undefined; + } + if ( + hasOwn('price') && + (typeof record['price'] !== 'number' || + !Number.isFinite(record['price']) || + record['price'] < 0) + ) { + return undefined; + } + const adm = expandAuctionPrice(admDescriptor.value, selectedCpm); + if (new TextEncoder().encode(adm).byteLength > MAX_CACHE_BODY_BYTES) return undefined; + return frozen({ + adm, + height: source.height, + type: 'adm', + version: 1, + width: source.width, + }); + } catch { + return undefined; + } +} + +async function readCacheBody(response: CacheFetchResponse): Promise { + let reader: CacheFetchReader | undefined; + let cancel: CacheFetchReader['cancel']; + let releaseLock: (() => void) | undefined; + try { + if ( + response.type === 'error' || + response.type === 'opaque' || + response.type === 'opaqueredirect' + ) { + return frozen({ ok: false, reason: 'cache_network_error' }); + } + if (!response.ok) return frozen({ ok: false, reason: 'cache_invalid_response' }); + if (!response.body) return frozen({ ok: true, text: '' }); + reader = response.body.getReader(); + cancel = reader.cancel; + releaseLock = reader.releaseLock; + if (typeof reader.read !== 'function' || typeof cancel !== 'function') { + return frozen({ ok: false, reason: 'cache_network_error' }); + } + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const step = await reader.read(); + if (step.done) break; + if (!isUint8Array(step.value)) { + return frozen({ ok: false, reason: 'cache_network_error' }); + } + total += step.value.byteLength; + if (total > MAX_CACHE_BODY_BYTES) { + try { + await cancel.call(reader); + } catch { + // The byte limit is authoritative even if stream cancellation is hostile. + } + return frozen({ ok: false, reason: 'cache_invalid_response' }); + } + arrayPush(chunks, step.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (let index = 0; index < chunks.length; index += 1) { + const chunk = chunks[index]; + if (!chunk) return frozen({ ok: false, reason: 'cache_network_error' }); + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return frozen({ + ok: true, + text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), + }); + } catch { + return frozen({ ok: false, reason: 'cache_network_error' }); + } finally { + if (reader && typeof releaseLock === 'function') { + try { + releaseLock.call(reader); + } catch { + // The bounded result remains authoritative if stream lock release is hostile. + } + } + } +} + function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { try { if ( @@ -1489,8 +1904,10 @@ function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { } } -/** Drive one admitted direct ADM attempt through the shared iframe constructor. */ -export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolean { +function renderAdmAttempt( + options: DirectAdmAttemptOptions, + admittedCacheAdm?: DirectAdmSource +): boolean { let attempt: RenderAttempt; let container: HTMLElement; let prepareIframe: DirectAdmIframeConstructor; @@ -1520,12 +1937,22 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea return false; } - const source = readDirectAdmSource(attempt.renderSource); + const source = admittedCacheAdm ?? readDirectAdmSource(attempt.renderSource); if (!source) { attempt.fail('winner_not_renderable'); return false; } - if (!attempt.beginDirect()) return false; + if (admittedCacheAdm === undefined && !attempt.beginDirect()) return false; + let artifactKind: CommittedRenderArtifact['kind']; + try { + const pathState = attempt.snapshot().state; + if (pathState === 'waiting_for_insertion') artifactKind = 'puc'; + else if (pathState === 'rendering_direct') artifactKind = 'direct_iframe'; + else return false; + } catch { + attempt.fail('internal_error'); + return false; + } let activeHandle: DirectAdmIframeHandle | undefined; let activateHandleMethod: DirectAdmIframeHandle['activate'] | undefined; @@ -1618,7 +2045,7 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea } const artifact = frozen({ - kind: 'direct_iframe', + kind: artifactKind, attemptId: attempt.id, slot: attempt.slot, navigationGeneration: attempt.navigationGeneration, @@ -1660,6 +2087,198 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea return state === 'waiting_for_adm' || state === 'accepted'; } +/** Drive one admitted direct ADM attempt through the shared iframe constructor. */ +export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolean { + return renderAdmAttempt(options); +} + +/** Fetch one admitted cache source, then enter the exact shared direct-ADM lifecycle. */ +export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): boolean { + let attempt: RenderAttempt; + let cachePolicy: DirectCacheAttemptOptions['cachePolicy']; + let container: HTMLElement; + let fetchCache: DirectCacheAttemptOptions['fetcher']; + let prepareIframe: DirectAdmIframeConstructor; + let publisherOrigin: string; + try { + attempt = options.attempt; + cachePolicy = options.cachePolicy; + container = options.container; + fetchCache = options.fetcher; + prepareIframe = options.prepareIframe; + publisherOrigin = options.publisherOrigin; + } catch { + return false; + } + if ( + !weakSetHas(renderAttempts, attempt) || + typeof fetchCache !== 'function' || + typeof prepareIframe !== 'function' + ) { + return false; + } + + let exactDocumentOrigin: boolean; + try { + exactDocumentOrigin = + !!directAdmDocument && + typeof directAdmOwnerDocumentGetter === 'function' && + reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && + directAdmDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDocumentOrigin = false; + } + if (!exactDocumentOrigin) { + attempt.fail('winner_not_renderable'); + return false; + } + + const source = readDirectCacheSource(attempt.renderSource, cachePolicy); + const winnerContext = attempt.winnerContext; + const selectedCpm = readSelectedCpm(winnerContext); + if (!source || selectedCpm === undefined) { + attempt.fail('descriptor_invalid'); + return false; + } + if (!attempt.beginCacheFetch()) return false; + + let controller: AbortController; + try { + controller = new AbortController(); + } catch { + attempt.fail('cache_network_error'); + return false; + } + + let pending = true; + const abortFetch = (): void => { + if (controller.signal.aborted) return; + try { + controller.abort(); + } catch { + // Abort is best-effort after the attempt has already settled. + } + }; + const failCache = (reason: RenderFailureReason): void => { + if (!pending) return; + pending = false; + abortFetch(); + try { + attempt.fail(reason); + } catch { + // A hostile attempt boundary cannot replay the already-closed cache phase. + } + }; + if ( + !attempt.onSettled(() => { + pending = false; + abortFetch(); + }) + ) { + failCache('cache_network_error'); + return false; + } + if (!pending) return false; + + let responsePromise: Promise; + try { + responsePromise = reflectApplyIntrinsic(fetchCache, undefined, [ + source.fetchUrl, + { + credentials: 'omit', + method: 'GET', + mode: 'cors', + redirect: 'error', + referrer: '', + referrerPolicy: 'no-referrer', + signal: controller.signal, + } satisfies RequestInit, + ]) as Promise; + } catch { + failCache('cache_network_error'); + return false; + } + + const complete = async (): Promise => { + let fetched: unknown; + try { + fetched = await responsePromise; + } catch { + failCache('cache_network_error'); + return; + } + if (!pending) return; + let response: CacheFetchResponse; + let responseOk: boolean; + let responseType: Response['type'] | undefined; + try { + if ((typeof fetched !== 'object' && typeof fetched !== 'function') || fetched === null) { + throw new TypeError('invalid cache response'); + } + response = fetched as CacheFetchResponse; + responseOk = response.ok; + responseType = response.type; + if (typeof responseOk !== 'boolean') throw new TypeError('invalid cache status'); + } catch { + failCache('cache_network_error'); + return; + } + if ( + responseType === 'error' || + responseType === 'opaque' || + responseType === 'opaqueredirect' + ) { + failCache('cache_network_error'); + return; + } + if (!responseOk) { + failCache('cache_http_error'); + return; + } + const body = await readCacheBody(response); + if (!pending) return; + if (!body.ok) { + failCache(body.reason); + return; + } + if (!attempt.cacheFetchCompleted()) { + failCache('cache_network_error'); + return; + } + const admSource = parseCacheAdm(body.text, source, selectedCpm); + if (!admSource) { + failCache('cache_invalid_response'); + return; + } + if (attempt.renderSource !== source || attempt.winnerContext !== winnerContext) { + failCache('cache_invalid_response'); + return; + } + pending = false; + try { + if ( + !renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, admSource) && + attempt.snapshot().outcome === undefined + ) { + attempt.fail('internal_error'); + } + } catch { + attempt.fail('internal_error'); + } + }; + const completion = complete(); + try { + reflectApplyIntrinsic(promiseThenIntrinsic, completion, [ + ignoreAsyncDisposal, + ignoreAsyncDisposal, + ]); + } catch { + failCache('cache_network_error'); + return false; + } + return true; +} + interface RendererNonceBinding { readonly nonce: string; readonly attempt: RenderAttempt; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index d3090859d..7152c697e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -373,6 +373,7 @@ describe('browser composition', () => { expect(session?.interfaces['rendererNonces']).toBe(rendererNonces); expect(session?.interfaces['renderDirectAps']).toBeTypeOf('function'); expect(session?.interfaces['renderDirectAdm']).toBeTypeOf('function'); + expect(session?.interfaces['renderDirectCache']).toBeTypeOf('function'); expect(session?.currentNavigation?.interfaces).toBe(session?.interfaces); expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 4e68cbefb..ce529a5ad 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -17,6 +17,7 @@ import { createRenderAttempt, createRendererNonceRegistry, createSlotOperation, + renderDirectCacheAttempt, renderDirectAdmAttempt, type CommittedRenderArtifact, type DirectAdmIframeConstructor, @@ -79,9 +80,23 @@ const DIRECT_APS_SOURCE = Object.freeze({ }); const WINNER_CONTEXT = Object.freeze({ selectedCpm: 1 }); +const CACHE_ID = 'f47447a0-b759-4f2f-9887-af458b79b570'; +const CACHE_POLICY = Object.freeze({ + version: 1 as const, + baseUrl: 'https://cache.example:8443/pbc/v1/cache', +}); +const CACHE_SOURCE = Object.freeze({ + type: 'cache' as const, + version: 1 as const, + cacheId: CACHE_ID, + fetchUrl: `${CACHE_POLICY.baseUrl}?uuid=${CACHE_ID}`, + width: 300, + height: 250, +}); function prepareRenderSource(candidate: unknown) { if (candidate === ADM_SOURCE) return ADM_SOURCE; + if (candidate === CACHE_SOURCE) return CACHE_SOURCE; if (candidate === APS_SOURCE) return APS_SOURCE; if (candidate === DIRECT_APS_SOURCE) return DIRECT_APS_SOURCE; return undefined; @@ -1912,6 +1927,580 @@ function claimed( return result; } +function corsResponse(body: BodyInit, status = 200): Response { + const response = new Response(body, { status }); + Object.defineProperty(response, 'type', { configurable: true, value: 'cors' }); + return response; +} + +function cacheResponse(body: Uint8Array) { + let delivered = false; + const cancel = vi.fn(async () => undefined); + return { + cancel, + response: Object.freeze({ + body: Object.freeze({ + getReader: () => + Object.freeze({ + cancel, + read: async () => { + if (delivered) return { done: true as const, value: undefined }; + delivered = true; + return { done: false as const, value: body }; + }, + releaseLock: vi.fn(), + }), + }), + ok: true, + type: 'cors' as const, + }) as unknown as Response, + }; +} + +async function insertedCacheFrame( + container: HTMLElement, + render?: RenderAttempt +): Promise { + await vi.waitFor(() => + expect({ + frame: container.querySelector('iframe'), + snapshot: render?.snapshot(), + }).toMatchObject({ + frame: expect.any(HTMLIFrameElement), + }) + ); + const frame = container.querySelector('iframe'); + if (!frame) throw new Error('should insert a cache ADM iframe'); + return frame; +} + +describe('direct cache attempt rendering', () => { + it('uses the exact bounded CORS request and renders validated OpenRTB ADM through the shared constructor', async () => { + document.body.innerHTML = '
'; + const context = Object.freeze({ selectedCpm: 1.25 }); + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, context)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const fetchCache = vi.fn(async () => + corsResponse( + JSON.stringify({ + adm: '
cached
', + w: 300, + h: 250, + price: 999, + id: 'fictional-openrtb-bid', + ext: { ignored: true }, + }) + ) + ); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); + const frame = await insertedCacheFrame(container, render); + + expect(fetchCache).toHaveBeenCalledWith(CACHE_SOURCE.fetchUrl, { + credentials: 'omit', + method: 'GET', + mode: 'cors', + redirect: 'error', + referrer: '', + referrerPolicy: 'no-referrer', + signal: expect.any(AbortSignal), + }); + expect(frame.srcdoc).toContain('data-price="1.25"'); + expect(frame.srcdoc).toContain('${AUCTION_PRICE:B64}'); + expect(frame.srcdoc).not.toContain('999'); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('accepts a same-origin basic response because request mode enforces CORS', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + const basicResponse = new Response(JSON.stringify({ adm: '
cached
' })); + Object.defineProperty(basicResponse, 'type', { configurable: true, value: 'basic' }); + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: async () => basicResponse, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + + const frame = await insertedCacheFrame(container, render); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('clears the fetch deadline at the final byte before preparing the ADM frame', async () => { + document.body.innerHTML = '
'; + const clear = vi.fn(); + const render = attempt(owner(), { + scheduler: Object.freeze({ + clear, + set: vi.fn(() => Object.freeze({})), + }), + }); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + const prepareIframe: DirectAdmIframeConstructor = (options) => { + expect(clear).toHaveBeenCalledTimes(1); + return prepareAdmIframe(options); + }; + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: async () => corsResponse(JSON.stringify({ adm: '
cached
' })), + prepareIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + + const frame = await insertedCacheFrame(container, render); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('keeps the admitted direct-cache winner context across delayed fetch and later winner changes', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, Object.freeze({ selectedCpm: 2.5 }))).toBe(true); + const container = document.getElementById('fictional-slot')!; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const later = attempt(owner(ATTEMPT_TWO, 'later-slot')); + expect(later.admitDirectWinner(CACHE_SOURCE, Object.freeze({ selectedCpm: 8.75 }))).toBe(true); + resolveFetch?.( + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 1000 })) + ); + const frame = await insertedCacheFrame(container); + + expect(frame.srcdoc).toContain('
2.5
'); + expect(frame.srcdoc).not.toContain('8.75'); + expect(frame.srcdoc).not.toContain('1000'); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('does not let the generic direct transition bypass the cache-specific deadline', () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect(render.beginDirect()).toBe(false); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'created' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it.each([ + [ + 'network rejection', + () => Promise.reject(new TypeError('fictional CORS failure')), + 'cache_network_error', + ], + ['HTTP status', () => Promise.resolve(corsResponse('{}', 503)), 'cache_http_error'], + ] as const)('maps %s to the exact typed cache failure', async (_case, fetchResult, reason) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: vi.fn(fetchResult), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ outcome: 'failed', reason }) + ); + expect(render.snapshot().outcome?.outcome).not.toBe('no_bid'); + document.body.innerHTML = ''; + }); + + it.each([ + ['opaque response', Object.freeze({ body: null, ok: true, type: 'opaque' })], + [ + 'throwing type accessor', + Object.defineProperties(Object.create(null), { + body: { enumerable: true, value: null }, + ok: { enumerable: true, value: true }, + type: { + enumerable: true, + get: () => { + throw new Error('hostile response type'); + }, + }, + }), + ], + [ + 'rejecting body reader', + Object.freeze({ + body: Object.freeze({ + getReader: () => + Object.freeze({ + cancel: vi.fn(), + read: async () => { + throw new Error('fictional stream failure'); + }, + releaseLock: vi.fn(), + }), + }), + ok: true, + type: 'cors', + }), + ], + ] as const)('contains a %s as cache_network_error', async (_case, response) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('renders a delayed owner-controlled cache claim as one PUC artifact', async () => { + document.body.innerHTML = '
placeholder
'; + const scope = owner(); + const artifacts = createCommittedArtifactStore(); + const render = attempt(scope, { artifacts }); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, CACHE_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const container = document.getElementById('fictional-slot')!; + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_insertion', + }); + expect(fetchCache).toHaveBeenCalledOnce(); + expect(resolveFetch).toBeTypeOf('function'); + resolveFetch?.( + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 9000 })) + ); + const frame = await insertedCacheFrame(container, render); + expect(frame.srcdoc).toContain('
1
'); + expect(frame.srcdoc).not.toContain('9000'); + + if (render.snapshot().outcome === undefined) frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(artifacts.current('fictional-slot')).toMatchObject({ + attemptId: render.id, + kind: 'puc', + }); + expect(container.querySelector('span')).toBeNull(); + artifacts.dispose(); + document.body.innerHTML = ''; + }); + + it.each([ + ['raw markup', '
raw
'], + ['array', JSON.stringify([{ adm: '
wrapped
' }])], + ['primitive', JSON.stringify('creative')], + ['wrapper', JSON.stringify({ bid: { adm: '
wrapped
' } })], + ['empty adm', JSON.stringify({ adm: '' })], + ['width alias', JSON.stringify({ adm: '
alias
', width: 300, height: 250 })], + ['unpaired w', JSON.stringify({ adm: '
unpaired
', w: 300 })], + ['fractional dimensions', JSON.stringify({ adm: '
fractional
', w: 300.5, h: 250 })], + ['out-of-range dimensions', JSON.stringify({ adm: '
large
', w: 4097, h: 250 })], + ['mismatched dimensions', JSON.stringify({ adm: '
wrong
', w: 728, h: 90 })], + ['negative price', JSON.stringify({ adm: '
price
', price: -1 })], + ] as const)('rejects a cache %s response shape', async (_case, body) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: vi.fn(async () => corsResponse(body)), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('enforces the 512 KiB streamed-body limit before JSON parsing', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const oversized = new Uint8Array(512 * 1024 + 1); + oversized.fill(0x20); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: vi.fn(async () => corsResponse(oversized)), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + document.body.innerHTML = ''; + }); + + it('cancels an oversized streamed body before publishing a failure', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const oversized = cacheResponse(new Uint8Array(512 * 1024 + 1)); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => oversized.response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + + expect(oversized.cancel).toHaveBeenCalledOnce(); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + + it('requires one frozen exact policy and canonical bounded cache source before fetching', () => { + const cases = [ + { + policy: { ...CACHE_POLICY }, + source: CACHE_SOURCE, + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${CACHE_SOURCE.fetchUrl}&uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://other.example/cache?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:8443/${'x'.repeat(4096)}?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${CACHE_SOURCE.fetchUrl}\n`, + }), + }, + ]; + + for (let index = 0; index < cases.length; index += 1) { + document.body.innerHTML = `
`; + const candidate = cases[index]!; + const render = attempt(owner(indexedAttemptId(index), `fictional-slot-${index}`), { + prepareRenderSource: (value) => (value === candidate.source ? candidate.source : undefined), + }); + expect(render.admitDirectWinner(candidate.source, WINNER_CONTEXT)).toBe(true); + const fetchCache = vi.fn(); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: candidate.policy, + container: document.getElementById(`fictional-slot-${index}`)!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(false); + expect(fetchCache).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'descriptor_invalid', + }); + } + document.body.innerHTML = ''; + }); + + it('aborts the cache request after five seconds and makes late work inert', async () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + let signal: AbortSignal | undefined; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn((_input: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }); + + try { + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.advanceTimersByTimeAsync(5_000); + expect(signal?.aborted).toBe(true); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }); + + resolveFetch?.(corsResponse(JSON.stringify({ adm: '
late
' }))); + await vi.runAllTimersAsync(); + expect(document.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }); + } finally { + vi.useRealTimers(); + document.body.innerHTML = ''; + } + }); + + it('aborts on caller cancellation and ignores a late cache response', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + let signal: AbortSignal | undefined; + let resolveFetch: ((response: Response) => void) | undefined; + const fetchCache = vi.fn((_input: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(render.cancel('caller_aborted')).toBe(true); + expect(signal?.aborted).toBe(true); + resolveFetch?.(corsResponse(JSON.stringify({ adm: '
late
' }))); + await Promise.resolve(); + await Promise.resolve(); + expect(document.querySelector('iframe')).toBeNull(); + expect(render.snapshot().outcome).toEqual({ outcome: 'cancelled', reason: 'caller_aborted' }); + document.body.innerHTML = ''; + }); +}); + function slotOperation(options: SlotOperationOptions): SlotOperation { const result = createSlotOperation(options); expect(result).toMatchObject({ ok: true }); From 6d9241e15a0aab92b4efaf50735a5a63ec8fb5b9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:46:48 -0700 Subject: [PATCH 295/494] Cover cache reservation authority --- .../lib/test/services/reservations.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index fa31fc3e9..b37dc0342 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -1502,7 +1502,7 @@ describe('Prebid admission leases and selection', () => { expect(service.recognize('native')).toEqual({ recognized: false }); }); - it('keeps a ten-second suppress-only lease, then atomically promotes the selected id to 15 minutes', () => { + it('promotes one selected cache lease from ten seconds to 15 minutes', () => { let now = 10; const { navigation } = runtimeNavigation(); const service = serviceAt(() => now); @@ -1512,7 +1512,7 @@ describe('Prebid admission leases and selection', () => { navigation, auctionId: 'fictional-auction', adUnitCode: 'fictional-slot', - renderSource: admSource(), + renderSource: cacheSource(), winnerContext: { selectedCpm: 1.25 }, prebidBid: bid, }; @@ -1549,6 +1549,20 @@ describe('Prebid admission leases and selection', () => { state: 'unselected', expiresAt: 10 + PREBID_ADMISSION_LEASE_MS, }); + const selected = claim(service, navigation, attempt, reservationId(1)); + const winnerContext = attempt.winnerContext; + if (!selected.recognized || !selected.claimed || !winnerContext) { + throw new Error('Expected the promoted cache lease to remain claimable'); + } + expect( + service.consumeClaim(selected, { + attempt, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: cacheSource(), winnerContext }); }); it('promotes only before the admission boundary and prunes at and after ten seconds', () => { @@ -1894,11 +1908,11 @@ describe('atomic claims and disposal', () => { expect(attempt.winnerContext).toBeUndefined(); expect(service.snapshotInventoryForTest().entriesWithPucSource).toBe(0); }); - it('transfers immutable context before consumption and preserves it after projection replacement', () => { + it('preserves one cache source and immutable context after projection replacement', () => { const { navigation } = runtimeNavigation(); const attempt = renderAttempt(navigation); const service = serviceAt(() => 0); - const source = admSource('
original winner
'); + const source = cacheSource(); const context = { selectedCpm: 7.5 }; service.registerRender({ reservationId: reservationId(), @@ -1937,6 +1951,19 @@ describe('atomic claims and disposal', () => { expect(attempt.winnerContext).toEqual({ selectedCpm: 7.5 }); expect(Object.isFrozen(attempt.winnerContext)).toBe(true); expect(service.recognize(reservationId())).toMatchObject({ state: 'consumed' }); + const winnerContext = attempt.winnerContext; + if (!result.recognized || !result.claimed || !winnerContext) { + throw new Error('Expected one claimed cache winner'); + } + expect( + service.consumeClaim(result, { + attempt: sink, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: navigation.generation, + winnerContext, + }) + ).toEqual({ renderSource: source, winnerContext }); }); it('allows exactly one of two simultaneous/reentrant claims and never replaces its PUC source', () => { From 799aa6e1e680cf8262c0e3944e1fd9b84b8bd6a2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:47:54 -0700 Subject: [PATCH 296/494] Refine the resilience implementation plan --- ...8-04-aps-tsjs-resilience-implementation.md | 659 ++++++++++++------ 1 file changed, 459 insertions(+), 200 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 95437e703..3b0562cbf 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -29,7 +29,7 @@ adapters. **Source of truth:** `docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md` revision 27, frozen review SHA -`6ed7fd4bafa31fe3a8112ad03ae5c600954d7568e6fef7ceabea5c9f8f94ab69`. This is the +`aab000fceaa4cfb303812fddf04b59aa172fd8034f4b5927acbf79d2ba180492`. This is the only implementation-plan document for the work. APS render and the runtime architecture are one coupled cutover: neither subsystem is useful or safe to release independently, so they remain in this one plan. @@ -59,9 +59,9 @@ safe to release independently, so they remain in this one plan. Every task ends with `git status --short`, focused verification, and one intentional commit before the next task. Stage only the exact paths from that task's **Files** list that the implementation changed; never use broad staging in a dirty worktree. -Use the task title as the commit subject, normalized to the repository's conventional -`test:`, `feat:`, `refactor:`, or `chore:` prefix. Task 19's coordinated production -switch is one atomic commit; do not split it into deployable half-states. +Use a descriptive sentence-case, imperative commit subject with no semantic prefix, +as required by `CLAUDE.md`. Task 19's coordinated production switch is one atomic +commit; do not split it into deployable half-states. ## Planned source shape @@ -605,26 +605,34 @@ collapse those checkpoints or carry unverified behavior between them. #### Task 5A: Define and test the common reserved-route and raw-proxy contract +**Task 5A files:** + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/integrations/mod.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-core/src/platform/http.rs` +- `crates/trusted-server-core/src/platform/mod.rs` +- `crates/trusted-server-core/src/platform/test_support.rs` +- `crates/trusted-server-core/src/platform/types.rs` + - [ ] **Step A1: Write failing reserved-family and raw-proxy contract tests.** - Cover enabled `GET /integrations/aps/runner.js`; APS-disabled local - `404 no-store`; negative `/integrations/aps/runner/v1.js` and malformed family - paths; `405` plus `Allow: GET`; and proof that no reserved path reaches publisher - auth, EC, or fallback. At the common platform boundary, assert exact upstream - target/request evidence, the five-second dispatch-through-final-byte deadline, - cancellation, body cap, closed response grammar, and replacement headers. Static - renderer bytes and policy remain Task 5C. + In `trusted-server-core` only, cover reserved-family classification and the + `ApsV1Integration`/platform test-support contract with a fake transport. Assert the + exact upstream target/request evidence, five-second dispatch-through-final-byte + policy, cancellation, body cap, closed response grammar, replacement headers, and + empty non-leaking failures. Do not add or run real-adapter route tests in 5A; + adapter dispatch and method behavior belong to 5B, and static renderer bytes/policy + belong to 5C. - [ ] **Step A2: Run the new focused tests and prove they fail.** ```bash cargo test-fastly integrations::aps - cargo test-axum --test routes - cargo test-cloudflare --test routes - cargo test-spin --test routes ``` - Expected: the live runner route and raw-proxy policy are not implemented. + Expected: the bounded raw-proxy policy/evidence contract and fake response + validation are not implemented; no adapter suite has been changed. - [ ] **Step A3: Define the bounded raw-proxy platform contract.** @@ -662,15 +670,70 @@ collapse those checkpoints or carry unverified behavior between them. support; it does not claim actual-runtime parity or renderer behavior. ```bash - cargo test --package trusted-server-core --target aarch64-apple-darwin integrations::aps + cargo test-fastly integrations::aps cargo fmt --all -- --check - git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/mod.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/platform + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/mod.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-core/src/platform/http.rs crates/trusted-server-core/src/platform/mod.rs crates/trusted-server-core/src/platform/test_support.rs crates/trusted-server-core/src/platform/types.rs git commit -m "Define the bounded APS runner proxy contract" ``` #### Task 5B: Implement and attest all four actual adapter transports -- [ ] **Step B1: Write and pass the complete actual-adapter proxy corpus.** +**Task 5B files:** + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-adapter-fastly/Cargo.toml` +- `crates/trusted-server-adapter-fastly/src/app.rs` +- `crates/trusted-server-adapter-fastly/src/main.rs` +- `crates/trusted-server-adapter-fastly/src/middleware.rs` +- `crates/trusted-server-adapter-fastly/src/platform.rs` +- `crates/trusted-server-adapter-axum/src/app.rs` +- `crates/trusted-server-adapter-axum/src/main.rs` +- `crates/trusted-server-adapter-axum/src/middleware.rs` +- `crates/trusted-server-adapter-axum/src/platform.rs` +- `crates/trusted-server-adapter-axum/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- `crates/trusted-server-adapter-cloudflare/build.sh` +- `crates/trusted-server-adapter-cloudflare/src/app.rs` +- `crates/trusted-server-adapter-cloudflare/src/lib.rs` +- `crates/trusted-server-adapter-cloudflare/src/middleware.rs` +- `crates/trusted-server-adapter-cloudflare/src/platform.rs` +- `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml` +- `crates/trusted-server-adapter-spin/Cargo.toml` +- `crates/trusted-server-adapter-spin/src/app.rs` +- `crates/trusted-server-adapter-spin/src/lib.rs` +- `crates/trusted-server-adapter-spin/src/middleware.rs` +- `crates/trusted-server-adapter-spin/src/platform.rs` +- `crates/trusted-server-adapter-spin/tests/routes.rs` +- `crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml` +- `crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml` +- `crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js` +- `crates/trusted-server-integration-tests/Cargo.toml` +- `crates/trusted-server-integration-tests/README.md` +- `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs` +- `crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs` +- `crates/trusted-server-integration-tests/tests/common/mod.rs` +- `crates/trusted-server-integration-tests/tests/common/runtime.rs` +- `crates/trusted-server-integration-tests/tests/environments/axum.rs` +- `crates/trusted-server-integration-tests/tests/environments/spin.rs` +- `crates/trusted-server-integration-tests/tests/environments/mod.rs` +- `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs` +- `crates/trusted-server-integration-tests/tests/environments/fastly.rs` +- `crates/trusted-server-integration-tests/tests/parity.rs` +- `scripts/integration-tests-aps-runner-proxy.sh` +- `scripts/integration-tests-browser.sh` +- `scripts/integration-tests.sh` +- `.github/workflows/integration-tests.yml` +- `.tool-versions` +- `Cargo.lock` +- `CLAUDE.md` + +- [ ] **Step B1: Write the failing actual-adapter route and proxy corpus.** Cover enabled + `GET /integrations/aps/runner.js`; APS-disabled local `404 no-store`; negative + `/integrations/aps/runner/v1.js` and malformed family paths; `405` plus + `Allow: GET`; and proof that no reserved path reaches publisher auth, EC, or + fallback through Fastly, Axum, Cloudflare, or Spin. Drive each real transport boundary—including Cloudflare and Spin wasm and full Fastly routes—against a controlled fictional upstream. Cover status other than @@ -723,7 +786,23 @@ collapse those checkpoints or carry unverified behavior between them. and transport seam for the local Fastly simulator only; it is not an APS runner pin, and no APS runner version, digest, or body enters the repository. -- [ ] **Step B2: Implement each actual adapter transport, the reserved dispatcher, and the live** +- [ ] **Step B2: Run the new adapter route/corpus tests and prove they fail before implementation.** + + ```bash + cargo test-fastly + cargo test-axum --test routes + cargo test-cloudflare --test routes + cargo test-spin --test routes + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + ``` + + Expected: each runtime is missing its raw transport and/or reserved live-runner + dispatch; no Task 5C static-renderer assertion is part of this red gate. + +- [ ] **Step B3: Implement each actual adapter transport, the reserved dispatcher, and the live** **proxy response.** Register the family ahead of auth/EC/fallback through one explicit test-only @@ -739,7 +818,7 @@ collapse those checkpoints or carry unverified behavior between them. no-referrer policy. Every upstream or validation failure returns a local empty `502 no-store`, with no vendor body or descriptor/capability data in logs. -- [ ] **Step B3: Run and commit adapter transport parity before adding the static renderer.** +- [ ] **Step B4: Run and commit adapter transport parity before adding the static renderer.** ```bash cargo test-fastly @@ -751,12 +830,34 @@ collapse those checkpoints or carry unverified behavior between them. ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin - git add crates/trusted-server-adapter-fastly crates/trusted-server-adapter-axum crates/trusted-server-adapter-cloudflare crates/trusted-server-adapter-spin crates/trusted-server-integration-tests scripts/integration-tests-aps-runner-proxy.sh scripts/integration-tests.sh .github/workflows/integration-tests.yml + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/registry.rs + git add crates/trusted-server-adapter-fastly/Cargo.toml crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/main.rs crates/trusted-server-adapter-fastly/src/middleware.rs crates/trusted-server-adapter-fastly/src/platform.rs + git add crates/trusted-server-adapter-axum/src/app.rs crates/trusted-server-adapter-axum/src/main.rs crates/trusted-server-adapter-axum/src/middleware.rs crates/trusted-server-adapter-axum/src/platform.rs crates/trusted-server-adapter-axum/tests/routes.rs + git add crates/trusted-server-adapter-cloudflare/Cargo.toml crates/trusted-server-adapter-cloudflare/build.sh crates/trusted-server-adapter-cloudflare/src/app.rs crates/trusted-server-adapter-cloudflare/src/lib.rs crates/trusted-server-adapter-cloudflare/src/middleware.rs crates/trusted-server-adapter-cloudflare/src/platform.rs crates/trusted-server-adapter-cloudflare/tests/routes.rs crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml + git add crates/trusted-server-adapter-spin/Cargo.toml crates/trusted-server-adapter-spin/src/app.rs crates/trusted-server-adapter-spin/src/lib.rs crates/trusted-server-adapter-spin/src/middleware.rs crates/trusted-server-adapter-spin/src/platform.rs crates/trusted-server-adapter-spin/tests/routes.rs + git add crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml crates/trusted-server-integration-tests/fixtures/configs/cloudflare-aps-runner-proxy-fixture.toml crates/trusted-server-integration-tests/fixtures/cloudflare/aps-runner-proxy-service.js crates/trusted-server-integration-tests/Cargo.toml crates/trusted-server-integration-tests/README.md + git add crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs crates/trusted-server-integration-tests/tests/common/mod.rs crates/trusted-server-integration-tests/tests/common/runtime.rs crates/trusted-server-integration-tests/tests/environments/axum.rs crates/trusted-server-integration-tests/tests/environments/spin.rs crates/trusted-server-integration-tests/tests/environments/mod.rs crates/trusted-server-integration-tests/tests/environments/cloudflare.rs crates/trusted-server-integration-tests/tests/environments/fastly.rs crates/trusted-server-integration-tests/tests/parity.rs + git add scripts/integration-tests-aps-runner-proxy.sh scripts/integration-tests-browser.sh scripts/integration-tests.sh .github/workflows/integration-tests.yml .tool-versions Cargo.lock CLAUDE.md git commit -m "Implement APS runner proxy parity" ``` #### Task 5C: Implement the static renderer and fictional browser fixture +**Task 5C files:** + +- `crates/trusted-server-core/src/integrations/aps.rs` +- `crates/trusted-server-core/src/integrations/registry.rs` +- `crates/trusted-server-adapter-fastly/src/app.rs` +- `crates/trusted-server-adapter-axum/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- `crates/trusted-server-adapter-spin/tests/routes.rs` +- `crates/trusted-server-integration-tests/tests/parity.rs` +- `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` +- `docs/guide/error-reference.md` +- `docs/guide/getting-started.md` +- `docs/guide/testing.md` + - [ ] **Step C1: Write failing static-renderer route and policy tests.** Cover `/integrations/aps/renderer/v1`, disabled and unknown-version local `404 no-store`, malformed family paths, `405` plus `Allow: GET`, and proof the @@ -765,7 +866,21 @@ collapse those checkpoints or carry unverified behavior between them. referrer policy, and deliberate absence of `X-Frame-Options` and CSP `frame-ancestors`. -- [ ] **Step C2: Implement and test the static renderer contract.** +- [ ] **Step C2: Run the static-renderer tests and prove they fail before implementation.** + + ```bash + cargo test-fastly integrations::aps + npm --prefix crates/trusted-server-js/lib run check:aps-contract + node --test crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs + TS_TEST_APS_V1=1 TS_BROWSER_FRAMEWORKS=nextjs TS_BROWSER_PROJECTS=chromium \ + ./scripts/integration-tests-browser.sh \ + tests/shared/aps-renderer.spec.ts --project=chromium + ``` + + Expected: the versioned renderer route/body/policy or renderer-document behavior + is missing while the already-committed live proxy remains green. + +- [ ] **Step C3: Implement and test the static renderer contract.** The renderer validates/clears the fragment nonce, accepts one exact source-bound parent port, validates the descriptor and kernel-captured publisher origin, and @@ -779,7 +894,7 @@ collapse those checkpoints or carry unverified behavior between them. from document acceptance. Mutable APS callback correctness is an accepted external trust dependency, not a fact TS can derive from script load or body inspection. -- [ ] **Step C3: Add the hermetic fictional runner fixture.** +- [ ] **Step C4: Add the hermetic fictional runner fixture.** Author a minimal local fixture that implements only the documented event and queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor @@ -787,7 +902,7 @@ collapse those checkpoints or carry unverified behavior between them. callback-silence, nested-iframe, and duplicate-callback tests. The fixture is not served as a production fallback and cannot be included in release bundles. -- [ ] **Step C4: Run the full route, transport, parity, and browser checks.** +- [ ] **Step C5: Run the full route, transport, parity, and browser checks.** ```bash cargo test-fastly @@ -804,11 +919,11 @@ collapse those checkpoints or carry unverified behavior between them. tests/shared/aps-renderer.spec.ts --project=chromium ``` -- [ ] **Step C5: Commit only the static renderer and fictional browser fixture after C1-C4** +- [ ] **Step C6: Commit only the static renderer and fictional browser fixture after C1-C5** are green. Adapter transport files must already be clean from Task 5B. ```bash - git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js scripts/integration-tests-browser.sh docs/guide/error-reference.md docs/guide/getting-started.md docs/guide/testing.md + git add crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/integrations/registry.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-axum/tests/routes.rs crates/trusted-server-adapter-cloudflare/tests/routes.rs crates/trusted-server-adapter-spin/tests/routes.rs crates/trusted-server-integration-tests/tests/parity.rs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js docs/guide/error-reference.md docs/guide/getting-started.md docs/guide/testing.md git commit -m "Implement the static APS renderer" ``` @@ -1010,7 +1125,7 @@ collapse those checkpoints or carry unverified behavior between them. crates/trusted-server-js/lib/eslint.config.js \ crates/trusted-server-js/lib/tsconfig.json \ crates/trusted-server-js/lib/vitest.config.ts - git commit -m "chore(tsjs): upgrade the package and TypeScript toolchain" + git commit -m "Upgrade the package and TypeScript toolchain" ``` Add only compatibility files that actually changed to the explicit staging list; @@ -1480,8 +1595,19 @@ collapse those checkpoints or carry unverified behavior between them. separate direct-cache context, plus URL/query/redirect/body/shape/macro cases, all three typed cache failures, and proof none becomes `no_bid`. -- [ ] **Step 5: Keep all remote side effects outside terminal correctness. APS has no synthetic** - notification. +- [ ] **Step 5: Finish render lifecycle behavior behind the test-only composition before any** + **production switch.** Snapshot render-relevant configuration when the attempt is + created, then re-check the navigation generation and the already-snapshotted + kill-switch state immediately before the earliest irreversible action: bridge + response, DOM insertion, or an existing non-APS notification. Prove a later + mutation cannot change an in-flight attempt and that an already-loaded page sees + configuration changes only through an existing response path; add no polling, + push channel, or event ingestion. + + Route existing non-APS `nurl`/`burl` behavior through the accepted terminal + transition so each notification initiates at most once and never blocks or changes + the render result. Add the explicit negative assertion that APS neither has nor + synthesizes either URL. Keep all remote side effects outside terminal correctness. - [ ] **Step 6: Run:** @@ -1747,8 +1873,11 @@ collapse those checkpoints or carry unverified behavior between them. - Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` - Modify: `crates/trusted-server-js/lib/src/services/targeting.ts` - Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/render.ts` +- Modify: `crates/trusted-server-js/lib/test/services/render.test.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` - Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- Modify: `crates/trusted-server-core/src/publisher.rs` - [ ] **Step 1: Add or preserve failing tests for early unconditional GPT subscriptions,** publisher services already enabled, SRA, disabled initial load, one refresh path, @@ -1828,11 +1957,34 @@ collapse those checkpoints or carry unverified behavior between them. quarantine, and complete timer/candidate/reference disposal. Successful handoff cancels reconciliation and transfers cleanup ownership synchronously. -- [ ] **Step 7: Run the entire GPT suite, not only new files:** +- [ ] **Step 7: Add the attributable-empty-cycle fallback corpus and implementation before the** + **switch.** Prove fallback begins only after an attributable TS-owned empty GAM + cycle; the primary child settles before fallback starts; publisher, ambiguous, + quarantined, timeout, and stale cases do not fall back; both child histories + remain immutable; and `SlotOperation` publishes exactly one final result with + `path:'fallback'` when the fallback child runs. Exercise this through the GPT + adapter, slot service, render service, and test-only browser composition; do not + wire a shipped entry point yet. + +- [ ] **Step 8: Implement and test the prospective real performance marks before the switch.** + Add a unit-tested server boot-script fragment that executes + `performance.mark('tsjs:bids-script')` at the bids/projection boundary but leave + its production call site unchanged. In the GPT adapter, implement the + exactly-once `performance.mark('tsjs:first-display')` transition at the first + authoritative display call. Exercise both through test-only composition, + including replay, publisher/non-authoritative display, stale generation, and + missing/throwing Performance API cases, and assert + `performance.measure('tsjs:boot-to-first-display', 'tsjs:bids-script', 'tsjs:first-display')` + uses those exact marks. `window.__tsjsPerf` is baseline-only scaffolding and is + rejected by the prospective post-switch test. + +- [ ] **Step 9: Run the entire GPT suite and prospective boot-mark tests, not only new files:** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt - npm --prefix crates/trusted-server-js/lib test -- --run test/adapters/googletag.test.ts test/services/slots.test.ts test/services/targeting.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/adapters/googletag.test.ts test/services/slots.test.ts test/services/targeting.test.ts test/services/render.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/composition/browser.test.ts + cargo test-fastly bids_script_performance_mark npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck ``` @@ -2004,6 +2156,8 @@ implementation change. - Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` - Modify: `crates/trusted-server-js/lib/src/core/trace.ts` - Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts` +- Create: `crates/trusted-server-js/lib/src/kernel/diagnostics.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts` - Modify: `crates/trusted-server-js/lib/src/services/context.ts` - Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` - Modify: `crates/trusted-server-js/lib/src/shared/async.ts` @@ -2040,6 +2194,7 @@ implementation change. - Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` - Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts` - Create: `crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` - Modify: `crates/trusted-server-js/lib/build-all.mjs` - Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `crates/trusted-server-core/src/trace_cookie.rs` @@ -2061,20 +2216,52 @@ implementation change. #### Task 18A: Rebuild creative as one independently green integration module +**Task 18A files:** + +- `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/click.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/iframe.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/image.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` +- `crates/trusted-server-js/lib/src/shared/async.ts` +- `crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts` +- `crates/trusted-server-js/lib/src/shared/origin.ts` +- `crates/trusted-server-js/lib/src/shared/scheduler.ts` +- `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/click.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts` +- `crates/trusted-server-js/lib/test/shared/async.test.ts` +- `crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts` +- `crates/trusted-server-js/lib/test/shared/scheduler.test.ts` +- `crates/trusted-server-js/lib/src/composition/browser.ts` +- `crates/trusted-server-js/lib/test/composition/browser.test.ts` + - [ ] **Step A1: Add the failing creative-only composition and lifecycle corpus.** Cover boot validation, guard enablement combinations, automatic scans, wrapper and observer ownership, hostile callbacks, startup rollback, disposal, and every existing click/image/iframe/proxy-sign behavior. Run creative alone and inside a manifest composition without modifying any other integration. -- [ ] **Step A2: Convert only creative into a thin integration module.** Its +- [ ] **Step A2: Run the creative slice before implementation and prove the new composition** + **cases fail for the missing module lifecycle.** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/shared/async.test.ts test/shared/dom_insertion_dispatcher.test.ts test/shared/scheduler.test.ts + ``` + +- [ ] **Step A3: Convert only creative into a thin integration module.** Its `_registerIntegration({id,release,prepare})` call is pure registration; `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, pre-registers disposal before every reversible mutation, and contributes at most one `afterCommit` callback. Keep shipped entry-point side effects unchanged until Task 19. -- [ ] **Step A3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate +- [ ] **Step A4: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate the complete plain-object shape, defaults, disabled/manifest mismatch, unknown keys, accessors, prototypes, and literals before preparation. Activation installs the click guard when `clickGuard` is true and dynamic image/iframe guards when @@ -2092,25 +2279,84 @@ implementation change. rejection of credentials, malformed values, and non-network schemes. Delete the mutable/install creative globals only in Task 22. -- [ ] **Step A4: Run and commit the creative slice before diagnostics or other modules.** +- [ ] **Step A5: Run and commit the creative slice before diagnostics or other modules.** ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/creative test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/shared/async.test.ts test/shared/dom_insertion_dispatcher.test.ts test/shared/scheduler.test.ts npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck - git add crates/trusted-server-js/lib/src/integrations/creative crates/trusted-server-js/lib/test/integrations/creative crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts + git add crates/trusted-server-js/lib/src/integrations/creative/index.ts crates/trusted-server-js/lib/src/integrations/creative/click.ts crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts crates/trusted-server-js/lib/src/integrations/creative/iframe.ts crates/trusted-server-js/lib/src/integrations/creative/image.ts crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts + git add crates/trusted-server-js/lib/test/integrations/creative/click.test.ts crates/trusted-server-js/lib/test/integrations/creative/helpers.ts crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts crates/trusted-server-js/lib/test/integrations/creative/image.test.ts crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts + git add crates/trusted-server-js/lib/src/shared/async.ts crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts crates/trusted-server-js/lib/src/shared/origin.ts crates/trusted-server-js/lib/src/shared/scheduler.ts crates/trusted-server-js/lib/src/shared/script_guard.ts crates/trusted-server-js/lib/test/shared/async.test.ts crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts crates/trusted-server-js/lib/test/shared/scheduler.test.ts crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts git commit -m "Prepare the creative integration module" ``` #### Task 18B: Rebuild diagnostics transport, producers, and consumers -- [ ] **Step B1: Move render tracing to the kernel diagnostics bus and exact public surface.** - `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and - `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot - navigation registry; prune on disposal. Keep document-runtime history at 200, - one row per physical impression, monotonic `count`/global `seq`, immutable `at`, - and non-weakening enrichment. Remove stale DOM stamp fields/badges on update and - preserve bounded overlay/export failure isolation. +**Task 18B files:** + +- `crates/trusted-server-core/src/publisher.rs` +- `crates/trusted-server-core/src/trace_cookie.rs` +- `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` +- `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` +- `crates/trusted-server-js/lib/src/core/trace.ts` +- `crates/trusted-server-js/lib/test/core/trace.test.ts` +- `crates/trusted-server-js/lib/src/kernel/diagnostics.ts` +- `crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts` +- `crates/trusted-server-js/lib/src/services/render.ts` +- `crates/trusted-server-js/lib/test/services/render.test.ts` +- `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts` +- `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts` +- `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts` +- `crates/trusted-server-js/lib/src/composition/browser.ts` +- `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step B1: Add the failing diagnostics slice before implementation.** Cover the + closure-private kernel bus, 15/16/17 integration-module subscriptions, + manifest-member identity, immutable post-correctness observations, subscriber + throw isolation, and proof that no publisher-facing API can obtain its register + or publish authority. Add failing render-trace, GPT-fact, producer-ordering, + inactive-zero-effect, composition, and `ts_console` request-pipeline cases. + +- [ ] **Step B2: Run the diagnostics slice and prove it fails at the missing internal bus,** + **producer wiring, and server session mechanics.** + + ```bash + cargo test-fastly trace_cookie + cargo test-fastly ts_console + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/diagnostics.test.ts test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics test/composition/browser.test.ts + ``` + +- [ ] **Step B3: Move render tracing to the kernel diagnostics bus and exact public surface.** + Implement the bus in `kernel/diagnostics.ts` as a closure-private runtime owner, + not a property reachable from `window.tsjs`, `TsjsApi`, diagnostics snapshots, or + publisher callbacks. Admit only identities from the validated manifest and at + most 16 live integration-module subscriptions; reject the seventeenth without + disturbing the first sixteen. Publisher code can use only the separately + bounded public read-only diagnostic subscriptions described below. + + `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and + `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot + navigation registry; prune on disposal. Keep document-runtime history at 200, one + row per physical impression, monotonic `count`/global `seq`, immutable `at`, and + non-weakening enrichment. Remove stale DOM stamp fields/badges on update and preserve + bounded overlay/export failure isolation. Commit correctness state before public delivery. Capture subscriber ids and enqueue frozen full records asynchronously in a 200-entry FIFO keyed by `seq`; same-sequence @@ -2120,7 +2366,7 @@ implementation change. registration-during-dispatch, callback throw isolation, and 199/200/201 overflow. Emit no `CustomEvent`, mutable trace global, or compatibility alias. -- [ ] **Step B2: Preserve GPT diagnostics through the adapter event stream.** Validate exact +- [ ] **Step B4: Preserve GPT diagnostics through the adapter event stream.** Validate exact `DiagnosticsBootV1` plus manifest activation before any listener/buffer exists. When active, core owns the six documented GPT observations before TS requests, buffers 512 raw facts until module activation, then replays and releases the @@ -2138,7 +2384,7 @@ implementation change. storage, upload, old flag, runtime expando, or `tsjs.gptDiagnostics` alias remains after Task 22. -- [ ] **Step B3: Rebuild and unit-test the server-owned `ts_console` browser-session mechanics.** +- [ ] **Step B5: Rebuild and unit-test the server-owned `ts_console` browser-session mechanics.** On eligible GET document navigations, accept exactly one case-sensitive `ts_console=1|true` enable directive or `0|false` disable directive; duplicate, conflicting, empty, or unknown values fail closed for that response. @@ -2147,8 +2393,13 @@ implementation change. host-only `Secure`, `HttpOnly`, `SameSite=Lax` session cookie. Assert same-origin tab/session behavior, disabled-by-default behavior, and that frozen `DiagnosticsBootV1.gpt.active` is the only browser-visible activation result. + Write request-pipeline tests named with `ts_console` before implementation and + prove they fail for directive stripping, method/document eligibility, + unrelated URL preservation, exact `Set-Cookie`, clearing, and boot-emitter + activation. These assertions must exercise `publisher.rs`, not only the + isolated trace-cookie parser. -- [ ] **Step B4: Wire every diagnostics producer explicitly after its correctness commit.** +- [ ] **Step B6: Wire every diagnostics producer explicitly after its correctness commit.** `RenderAttempt` publishes immutable render observations only after terminal or accepted-artifact state commits; the sole GPT adapter publishes its six raw facts only after adapter bookkeeping commits. Both use the kernel-owned bus, @@ -2157,21 +2408,75 @@ implementation change. event ordering, enrichment replacement, buffer release, navigation disposal, and absence of `CustomEvent`, mutable globals, or a second GPT listener set. -- [ ] **Step B5: Run and commit diagnostics transport, producer, and consumer wiring as one** +- [ ] **Step B7: Run and commit diagnostics transport, producer, and consumer wiring as one** independently green slice. ```bash - cargo test --package trusted-server-core --target aarch64-apple-darwin trace_cookie - npm --prefix crates/trusted-server-js/lib test -- --run test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics + cargo test-fastly trace_cookie + cargo test-fastly ts_console + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/diagnostics.test.ts test/core/trace.test.ts test/services/render.test.ts test/adapters/googletag.test.ts test/integrations/gpt_diagnostics + npm --prefix crates/trusted-server-js/lib test -- --run test/composition/browser.test.ts npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck - git add crates/trusted-server-core/src/trace_cookie.rs crates/trusted-server-core/src/integrations/gpt_diagnostics.rs crates/trusted-server-js/lib/src/core/trace.ts crates/trusted-server-js/lib/src/services crates/trusted-server-js/lib/src/adapters/googletag.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics crates/trusted-server-js/lib/test/core/trace.test.ts crates/trusted-server-js/lib/test/services/render.test.ts crates/trusted-server-js/lib/test/adapters/googletag.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics + git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/trace_cookie.rs crates/trusted-server-core/src/integrations/gpt_diagnostics.rs crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js + git add crates/trusted-server-js/lib/src/kernel/diagnostics.ts crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts crates/trusted-server-js/lib/src/core/trace.ts crates/trusted-server-js/lib/test/core/trace.test.ts crates/trusted-server-js/lib/src/services/render.ts crates/trusted-server-js/lib/test/services/render.test.ts crates/trusted-server-js/lib/src/adapters/googletag.ts crates/trusted-server-js/lib/test/adapters/googletag.test.ts + git add crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts + git add crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts + git add crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts git commit -m "Rebuild bounded runtime diagnostics" ``` #### Task 18C: Migrate the remaining integrations and maximal manifest -- [ ] **Step C1: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome +**Task 18C files:** + +- `crates/trusted-server-js/lib/src/integrations/datadome/index.ts` +- `crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts` +- `crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/lockr/index.ts` +- `crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/osano/index.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/index.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/segments.ts` +- `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- `crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` +- `crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/osano/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts` +- `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` +- `crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts` +- `crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts` +- `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` +- `crates/trusted-server-js/lib/src/services/context.ts` +- `crates/trusted-server-js/lib/test/services/context.test.ts` +- `crates/trusted-server-js/lib/src/shared/beacon_guard.ts` +- `crates/trusted-server-js/lib/src/shared/globals.ts` +- `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- `crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts` +- `crates/trusted-server-js/lib/src/composition/browser.ts` +- `crates/trusted-server-js/lib/test/composition/browser.test.ts` +- `crates/trusted-server-js/lib/build-all.mjs` +- `crates/trusted-server-core/src/integrations/datadome.rs` +- `crates/trusted-server-core/src/integrations/datadome/protection.rs` +- `crates/trusted-server-core/src/integrations/datadome/protection_scope.rs` +- `crates/trusted-server-core/src/integrations/didomi.rs` +- `crates/trusted-server-core/src/integrations/google_tag_manager.rs` +- `crates/trusted-server-core/src/integrations/lockr.rs` +- `crates/trusted-server-core/src/integrations/osano.rs` +- `crates/trusted-server-core/src/integrations/permutive.rs` +- `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- `crates/trusted-server-core/src/integrations/testlight.rs` +- `crates/trusted-server-core/src/integrations/mod.rs` + +- [ ] **Step C1: Add the failing remaining-integration and maximal-manifest corpus.** Preserve + each `rc/july` integration behavior exactly. Cover DataDome script/preload path rewriting; Didomi absolute SDK path without config clobber; GTM script/preload and GA beacon/fetch rewriting; Lockr bounded readiness and API host; Osano USP/GPP/TCF marker ownership and lifecycle; Permutive bounded @@ -2184,21 +2489,30 @@ implementation change. provider survives failed activation or module/runtime disposal, and SPA navigation does not register a duplicate. -- [ ] **Step C2: Convert only the remaining integrations into thin modules.** Each + Load core followed by every server-declared integration in manifest order and + assert one runtime, no unknown id, no duplicate activation, exact reverse-order + disposal, and no leaked timer, listener, wrapper, observer, context provider, or + queued continuation. Run each module alone and in the maximal manifest with missing + globals, timeout, malformed config/consent/storage, matcher false positives, + callback throws, startup failure, and cross-integration isolation. + +- [ ] **Step C2: Run the complete new corpus before conversion and prove the module lifecycle** + **and maximal-manifest cases fail.** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/datadome test/integrations/didomi test/integrations/google_tag_manager test/integrations/lockr test/integrations/osano test/integrations/permutive test/integrations/sourcepoint test/integrations/testlight + npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts test/shared/beacon_guard.test.ts test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib run test:release + cargo test-fastly publisher + ``` + +- [ ] **Step C3: Convert only the remaining integrations into thin modules.** Each `_registerIntegration({id,release,prepare})` call is pure registration; `prepare(ctx)` is inert and Promise-returning; `activate(ctx)` is synchronous, pre-registers disposal before reversible mutation, and contributes at most one `afterCommit`. Shared helpers must preserve each integration's exact matcher, startup order, failure isolation, and disposal semantics. -- [ ] **Step C3: Add the maximal-bundle failing smoke test.** Load core followed by every - server-declared integration in manifest order and assert one runtime, no unknown - id, no duplicate activation, exact reverse-order disposal, and no leaked timer, - listener, wrapper, observer, context provider, or queued continuation. Run each - module alone and in the maximal manifest with missing globals, timeout, malformed - config/consent/storage, matcher false positives, callback throws, startup - failure, and cross-integration isolation. - - [ ] **Step C4: Generate and test the prospective manifest member list/order from the exact** enabled bundle list. Embed the same release id in core and every integration IIFE. Add failures for integration before core, unknown/missing/duplicate member, @@ -2222,39 +2536,22 @@ implementation change. diagnostics changes from Tasks 18A/18B into this commit. ```bash - git add crates/trusted-server-js/lib/src/integrations crates/trusted-server-js/lib/test/integrations crates/trusted-server-js/lib/src/shared crates/trusted-server-js/lib/test/shared crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-core/src/integrations + git add crates/trusted-server-js/lib/src/integrations/datadome/index.ts crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts crates/trusted-server-js/lib/src/integrations/didomi/index.ts crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts + git add crates/trusted-server-js/lib/src/integrations/lockr/index.ts crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts crates/trusted-server-js/lib/src/integrations/osano/index.ts crates/trusted-server-js/lib/src/integrations/permutive/index.ts crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts crates/trusted-server-js/lib/src/integrations/permutive/segments.ts + git add crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts crates/trusted-server-js/lib/src/integrations/testlight/index.ts + git add crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/osano/index.test.ts crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts + git add crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts + git add crates/trusted-server-js/lib/src/services/context.ts crates/trusted-server-js/lib/test/services/context.test.ts crates/trusted-server-js/lib/src/shared/beacon_guard.ts crates/trusted-server-js/lib/src/shared/globals.ts crates/trusted-server-js/lib/src/shared/script_guard.ts crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts crates/trusted-server-js/lib/src/composition/browser.ts crates/trusted-server-js/lib/test/composition/browser.test.ts crates/trusted-server-js/lib/test/build/release-v1.test.mjs crates/trusted-server-js/lib/build-all.mjs + git add crates/trusted-server-core/src/integrations/datadome.rs crates/trusted-server-core/src/integrations/datadome/protection.rs crates/trusted-server-core/src/integrations/datadome/protection_scope.rs crates/trusted-server-core/src/integrations/didomi.rs crates/trusted-server-core/src/integrations/google_tag_manager.rs crates/trusted-server-core/src/integrations/lockr.rs crates/trusted-server-core/src/integrations/osano.rs crates/trusted-server-core/src/integrations/permutive.rs crates/trusted-server-core/src/integrations/sourcepoint.rs crates/trusted-server-core/src/integrations/testlight.rs crates/trusted-server-core/src/integrations/mod.rs git commit -m "Prepare the remaining integration modules" ``` -### Task 19: Complete lifecycle behavior and perform the coordinated production switch +### Task 19: Perform the coordinated production wiring switch **Files:** -- Modify: `crates/trusted-server-js/lib/src/services/render.ts` -- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` -- Modify: `crates/trusted-server-js/lib/src/services/projections.ts` -- Modify: `crates/trusted-server-js/lib/src/services/targeting.ts` -- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts` -- Modify: `crates/trusted-server-js/lib/src/services/auction_batch.ts` -- Modify: `crates/trusted-server-js/lib/src/services/context.ts` -- Modify: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` -- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts` -- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts` -- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` -- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` -- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` -- Modify: `crates/trusted-server-js/lib/src/core/config.ts` -- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` -- Modify: `crates/trusted-server-js/lib/src/core/log.ts` -- Modify: `crates/trusted-server-js/lib/src/core/queue.ts` -- Modify: `crates/trusted-server-js/lib/src/core/registry.ts` -- Modify: `crates/trusted-server-js/lib/src/core/trace.ts` -- Modify: `crates/trusted-server-js/lib/src/core/types.ts` -- Modify: `crates/trusted-server-js/lib/src/core/request.ts` -- Modify: `crates/trusted-server-js/lib/src/core/auction.ts` - Modify: `crates/trusted-server-js/lib/src/core/index.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` -- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` @@ -2277,7 +2574,6 @@ implementation change. - Modify: `crates/trusted-server-adapter-axum/src/app.rs` - Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` - Modify: `crates/trusted-server-adapter-spin/src/app.rs` -- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` - Modify: `crates/trusted-server-core/src/html_processor.rs` - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - Modify: `crates/trusted-server-core/src/integrations/didomi.rs` @@ -2285,50 +2581,9 @@ implementation change. - Modify: `crates/trusted-server-core/src/integrations/gpt.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` -- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` - Modify: `crates/trusted-server-js/lib/build-all.mjs` -- Modify: `crates/trusted-server-js/lib/test/core/index.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` -- Modify: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/render.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/projections.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/reservations.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/auction_batch.test.ts` -- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/registry.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts` -- Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts` -- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` -- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` -- Modify: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` -- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` - -- [ ] **Step 1: Add failing tests proving fallback begins only after an attributable TS-owned** - empty GAM cycle; the primary child settles before fallback starts; publisher, - ambiguous, quarantined, timeout, and stale cases do not fall back; both child - histories remain immutable; and `SlotOperation` publishes exactly one final - result with `path:'fallback'` when the child runs. - -- [ ] **Step 2: Snapshot render-relevant configuration at attempt creation. Re-check generation** - and existing kill-switch state immediately before the earliest irreversible - action (bridge response, DOM insertion, or an existing non-APS notification). -- [ ] **Step 3: Preserve existing non-APS `nurl`/`burl` behavior but route it through the attempt** - terminal transition so it initiates once and never blocks. Add an assertion that - APS never synthesizes either URL. - -- [ ] **Step 4: Test already-loaded-page limits honestly: configuration changes reach a page only** - through an existing response path; do not add polling, push, or event ingestion. - -- [ ] **Step 5: Complete the pre-switch checklist with no production-wiring changes staged.** +- [ ] **Step 1: Complete the pre-switch checklist with no production-wiring changes staged.** The atomic switch is allowed to flip wiring only after every behavior suite below is already green against the test-only composition and prospective routes/artifacts: @@ -2349,20 +2604,22 @@ implementation change. rebased into the immutable pre-change artifact; the gate must be green after the atomic switch and Task 22 legacy deletion, before release readiness. - Install the real performance marks before this checklist closes. Execute - `performance.mark('tsjs:bids-script')` in the actual server-emitted bids/projection - boot script, and execute `performance.mark('tsjs:first-display')` exactly once at - the first authoritative GPT display call in the real adapter path. The browser - performance fixture must measure those marks with - `performance.measure('tsjs:boot-to-first-display', 'tsjs:bids-script', 'tsjs:first-display')`; - `window.__tsjsPerf` remains baseline-capture scaffolding and cannot satisfy the - post-switch gate. + Verify the prospective performance-mark tests from Task 16 are already green: the + unit-tested server fragment names `tsjs:bids-script`, the adapter names exactly one + authoritative `tsjs:first-display`, and the measure uses those exact marks. Task 19 + may connect only their already-tested production call sites; it may not add or + repair mark behavior. `window.__tsjsPerf` remains baseline-capture scaffolding and + cannot satisfy the post-switch gate. ```bash + git diff --cached --quiet + npm --prefix crates/trusted-server-js/lib run test:release + npm --prefix crates/trusted-server-js/lib run test:architecture + npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/kernel/runtime.test.ts test/composition/browser.test.ts npm --prefix crates/trusted-server-js/lib test -- --run \ test/services test/kernel test/adapters test/core npm --prefix crates/trusted-server-js/lib test -- --run \ - test/integrations test/composition test/build + test/integrations test/composition npm --prefix crates/trusted-server-js/lib run build npm --prefix crates/trusted-server-js/lib run lint npm --prefix crates/trusted-server-js/lib run typecheck @@ -2371,70 +2628,30 @@ implementation change. cargo test-cloudflare cargo test-spin cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin ``` -- [ ] **Step 6: Atomically activate the new production surface in one task and one commit:** - - `/auction` emits/parses only the exact decision-set/tagged-source wire, and - initial HTML/page-bids emit only `tsjs.boot.auctionProjection`; - - the immutable initial projection seeds the first `NavigationSession`; every SPA - page-bids response validates and commits only to the replacement session's - internal projection and never mutates recursively frozen `tsjs.boot`; - - projection parsing enforces the exact 256-array/member, identifier, targeting, - currency/CPM, reservation, dimension, and canonical 8 MiB bounds before mutation; - an over-cap projection converts every otherwise winning decision to - `winner_not_renderable`, emits no projected bid, and omits the corresponding - `/auction` TS seatbid; - - the server emits exact frozen `TsjsBootV1`, `CreativeBootV1`, - `DiagnosticsBootV1`, and `BootManifestV1` before core from generated release - metadata, after validating every integration config and manifest relationship; - - core inertly prepares every required integration in manifest order while no - bridge/listener/global mutation is live. Only after all Promises resolve does the - same-task synchronous activation barrier install the capture bridge as its first - reversible core effect, install correctness GPT listeners, and activate modules - in order with monotonic pre/post-call and pre-handoff checks. Failure rolls back - every reversible effect; success commits the complete `TsjsApi`, runs staged - `afterCommit` callbacks in manifest order, and drains the preload queue; - - the preload queue handoff uses the exact real-Array algorithm: capture ingress, - install the fixed installing descriptor, snapshot, forward retained ingress - pushes, install the frozen final actual Array with own immediate `push` and - `length:0`, publish the complete API, run `afterCommit`, then drain snapshot plus - forwarded work exactly once. Native/borrowed mutators and retained references - cannot retain entries or create a second runtime; - - the kernel surface is exactly `TsjsApi` with semantic `version`, exact - `releaseId`, immutable `boot`, real `que`, `addAdUnits`, Promise `requestAds`, - local `log`, diagnostics, `_registerIntegration`, and frozen status-only - `_internal`. Fallback exposes its exact smaller own surface, validates then refuses - `addAdUnits`, settles known slots with the committed fallback reason, drains the - queue once, and creates no runtime/adapters/listeners/timers/DOM work; - - `addAdUnits` transactionally validates and registers programmatic direct-auction - slots against the same combined 256-slot cap, exact identifier/bidder/dimension - grammar, and collision indexes. Omitted-slot `requestAds` snapshots server and - programmatic registrations in ordinal order; later registrations cannot enter an - in-flight snapshot; - - GPT, Prebid, APS, creative, diagnostics, all remaining integrations, Promise - `requestAds`, versioned APS renderer client, and generated bootstrap/fallback - switch together on the shared sessions/services and terminal latches; - - the external publisher artifact switches as independently useful pure Prebid.js - 10.26.0 with its own watchdog and frozen artifact stamp; TS admission, render, - refresh, targeting, and release matching remain only in the separate Prebid - integration module; - - all adapters atomically register only the versioned static renderer and - unversioned live `/integrations/aps/runner.js` proxy; the abandoned - `/integrations/aps/runner/v1.js` and unversioned renderer are local negative - routes; - - every Rust/JS integration config emitter moves its existing values from - scattered `window.__tsjs_*` globals into its exact `tsjs.boot.*` member before - the corresponding integration prepares; no integration loses configuration; - - accepted artifacts, `WinnerContext`, targeting journals, renderer reservations, - GPT physical-object reconciliation, and navigation ownership use the shared - services; and - - render trace and GPT diagnostics commit only after correctness transitions and - expose their exact bounded asynchronous frozen APIs. Creative guards auto-install - from frozen boot configuration and both-false guards have zero DOM side effects; - and - - the real boot/render path records the named `tsjs:bids-script` and - `tsjs:first-display` performance marks at their authoritative transitions; the - temporary `__tsjsPerf` baseline shim is not carried into the switched runtime. +- [ ] **Step 2: Atomically switch production wiring in one task and one commit.** Make no + validator, state-machine, lifecycle, adapter, or test behavior changes here: + - point `/auction`, initial HTML, and page-bids production emitters at the + already-tested exact decision/projection serializers and boot-script fragments, + including the preimplemented `tsjs:bids-script` mark; + - make the sole browser composition root construct the already-tested runtime, + services, adapters, integration modules, fallback, and queue handoff, then have + each thin integration `index.ts` delegate to that composition without retaining a + second registry or behavior branch; + - switch generated release/manifest/config/bootstrap emission and the independently + built pure Prebid 10.26.0 artifact to those already-tested entry points; and + - register the already-tested versioned APS renderer and live unversioned runner + proxy through all four adapter dispatchers while preserving the negative routes. + + The switch is a hard cutover: add no selector, dual manifest, compatibility alias, + protocol autodetection, or fallback to old behavior. The old implementation may + remain physically present only while unreachable; Task 22 deletes it before + release. Before enabling the Fastly production route, run the unchanged stall/slow-drip deadline cases through a non-production Fastly Compute service and a controlled @@ -2448,17 +2665,56 @@ implementation change. manifest, or shape autodetection. The temporarily unused server routes and old declarations are deleted in Task 22 before release. -- [ ] **Step 7: Run:** +- [ ] **Step 3: Stage only the wiring allowlist, prove the diff contains no behavior/test files,** + **run the full gate, and commit.** Any required behavior or test repair fails this + checkpoint and returns to the owning earlier task; do not widen the allowlist. ```bash + git add \ + crates/trusted-server-js/lib/src/core/index.ts \ + crates/trusted-server-js/lib/src/composition/browser.ts \ + crates/trusted-server-js/lib/src/integrations/{gpt,prebid,creative,datadome,didomi,google_tag_manager,gpt_diagnostics,lockr,osano,permutive,sourcepoint,testlight}/index.ts \ + crates/trusted-server-js/lib/build-all.mjs + git add \ + crates/trusted-server-core/src/integrations/gpt_bootstrap.js \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/tsjs.rs \ + crates/trusted-server-core/src/auction/{endpoints,formats}.rs \ + crates/trusted-server-core/src/integrations/{registry,prebid,didomi,sourcepoint,gpt,gpt_diagnostics}.rs \ + crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js \ + crates/trusted-server-core/src/html_processor.rs + git add \ + crates/trusted-server-adapter-fastly/src/app.rs \ + crates/trusted-server-adapter-axum/src/app.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ + crates/trusted-server-adapter-spin/src/app.rs + git diff --name-only --cached | awk ' + /^(crates\/trusted-server-js\/lib\/(src\/core\/index\.ts|src\/composition\/browser\.ts|src\/integrations\/(gpt|prebid|creative|datadome|didomi|google_tag_manager|gpt_diagnostics|lockr|osano|permutive|sourcepoint|testlight)\/index\.ts|build-all\.mjs)|crates\/trusted-server-core\/src\/(integrations\/gpt_bootstrap\.js|publisher\.rs|tsjs\.rs|auction\/(endpoints|formats)\.rs|integrations\/(registry|prebid|didomi|sourcepoint|gpt|gpt_diagnostics)\.rs|integrations\/gpt_diagnostics_bootstrap\.js|html_processor\.rs)|crates\/trusted-server-adapter-(fastly|axum|cloudflare|spin)\/src\/app\.rs)$/ { next } + { print "unexpected non-wiring path: " $0; bad = 1 } + END { exit bad ? 1 : 0 } + ' + git diff --exit-code --cached -- \ + crates/trusted-server-js/lib/src/services \ + crates/trusted-server-js/lib/src/kernel \ + crates/trusted-server-js/lib/src/adapters \ + crates/trusted-server-js/lib/test npm --prefix crates/trusted-server-js/lib test -- --run test/services test/core test/integrations/gpt npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps test/kernel + npm --prefix crates/trusted-server-js/lib run test:release + npm --prefix crates/trusted-server-js/lib run test:architecture npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck cargo test-fastly cargo test-axum cargo test-cloudflare cargo test-spin cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity + ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum + ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly + ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare + ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin + git commit -m "Switch production to the resilient TSJS runtime" ``` ### Phase 4 exit @@ -2466,7 +2722,7 @@ implementation change. - GPT, Prebid, APS, and all integration entry points use one kernel/integration-module surface. - The old registries, sentinels, expandos, refresh wrappers, and bridge branches are - gone. + unreachable behind production wiring; physical deletion remains Task 22. - All Vitest and production-bundle tests pass. ## Phase 5 — browser conformance, deletion, and release readiness @@ -2582,6 +2838,9 @@ implementation change. tests/shared/aps-renderer.spec.ts \ tests/shared/aps-puc-lifecycle.spec.ts \ tests/shared/tsjs-runtime.spec.ts \ + tests/shared/creative-sandbox.spec.ts \ + tests/nextjs/gpt-diagnostics.spec.ts \ + tests/nextjs/navigation.spec.ts \ --project=chromium --project=firefox --project=webkit ``` From 06314ee40303c7601c9b88452f08c5599564a4b3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:54:08 -0700 Subject: [PATCH 297/494] Complete the resilience plan review gates --- .../2026-08-04-aps-tsjs-resilience-implementation.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 3b0562cbf..8f41b7d94 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -74,6 +74,7 @@ crates/trusted-server-js/lib/src/ identity.ts navigation-prefix + u64 attempts; 128-bit CSPRNG tickets/nonces disposable.ts owned disposer stack and terminal latch primitives integration_registry.ts release-matched prepare/activate transaction + diagnostics.ts bounded failure-isolated internal diagnostics bus runtime.ts bootstrap ownership and shared Runtime object sessions.ts RuntimeSession and NavigationSession adapters/ @@ -793,6 +794,7 @@ collapse those checkpoints or carry unverified behavior between them. cargo test-axum --test routes cargo test-cloudflare --test routes cargo test-spin --test routes + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare @@ -870,6 +872,10 @@ collapse those checkpoints or carry unverified behavior between them. ```bash cargo test-fastly integrations::aps + cargo test-axum --test routes + cargo test-cloudflare --test routes + cargo test-spin --test routes + cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity npm --prefix crates/trusted-server-js/lib run check:aps-contract node --test crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs TS_TEST_APS_V1=1 TS_BROWSER_FRAMEWORKS=nextjs TS_BROWSER_PROJECTS=chromium \ @@ -2502,6 +2508,7 @@ implementation change. ```bash npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/datadome test/integrations/didomi test/integrations/google_tag_manager test/integrations/lockr test/integrations/osano test/integrations/permutive test/integrations/sourcepoint test/integrations/testlight npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts test/shared/beacon_guard.test.ts test/composition/browser.test.ts + npm --prefix crates/trusted-server-js/lib run build npm --prefix crates/trusted-server-js/lib run test:release cargo test-fastly publisher ``` @@ -2613,6 +2620,7 @@ implementation change. ```bash git diff --cached --quiet + npm --prefix crates/trusted-server-js/lib run build npm --prefix crates/trusted-server-js/lib run test:release npm --prefix crates/trusted-server-js/lib run test:architecture npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/kernel/runtime.test.ts test/composition/browser.test.ts From b781a1bf8c681e08f27581a5e495f22216bd1d76 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:59:08 -0700 Subject: [PATCH 298/494] Add the Universal Creative bridge dispatcher --- .../lib/src/adapters/messaging.ts | 39 ++++ .../lib/src/services/puc_bridge.ts | 216 ++++++++++++++++++ .../lib/test/adapters/messaging.test.ts | 73 ++++++ .../lib/test/services/puc_bridge.test.ts | 215 +++++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/services/puc_bridge.ts create mode 100644 crates/trusted-server-js/lib/test/services/puc_bridge.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index 492e411af..ff50f86cd 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -219,6 +219,13 @@ export interface MessagingAdapter { transferred: readonly MessagingPort[] ): boolean; installCaptureListener(listener: CaptureMessageListener): () => void; + inspectGlobalMessage(candidate: unknown): + | Readonly<{ + message: string; + adId?: string; + lifecycleTicket?: string; + }> + | undefined; parseProtocolMessage( kind: ProtocolMessageKind, candidate: unknown @@ -491,6 +498,36 @@ function parseGlobalJson(candidate: unknown): unknown { } } +function inspectGlobalMessage( + candidate: unknown +): Readonly<{ message: string; adId?: string; lifecycleTicket?: string }> | undefined { + try { + const decoded = typeof candidate === 'string' ? parseGlobalJson(candidate) : candidate; + if (typeof decoded !== 'object' || decoded === null) return undefined; + const prototype = Object.getPrototypeOf(decoded); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(decoded); + const message = descriptors['message']; + if (!message || !Object.prototype.hasOwnProperty.call(message, 'value')) return undefined; + if (typeof message.value !== 'string') return undefined; + const adId = descriptors['adId']; + const lifecycleTicket = descriptors['lifecycleTicket']; + if (adId && !Object.prototype.hasOwnProperty.call(adId, 'value')) return undefined; + if (lifecycleTicket && !Object.prototype.hasOwnProperty.call(lifecycleTicket, 'value')) { + return undefined; + } + return Object.freeze({ + message: message.value, + ...(adId && typeof adId.value === 'string' ? { adId: adId.value } : {}), + ...(lifecycleTicket && typeof lifecycleTicket.value === 'string' + ? { lifecycleTicket: lifecycleTicket.value } + : {}), + }); + } catch { + return undefined; + } +} + function exactRecord( candidate: unknown, keys: readonly string[] @@ -1307,6 +1344,7 @@ export function createBrowserMessagingAdapter( rollback(); }; }, + inspectGlobalMessage, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, validation), extractTransferredPorts, @@ -1319,6 +1357,7 @@ export function createNoopMessagingAdapter(): MessagingAdapter { createChannel: () => undefined, postWindow: () => false, installCaptureListener: () => () => undefined, + inspectGlobalMessage, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), extractTransferredPorts, diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts new file mode 100644 index 000000000..be4772c05 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -0,0 +1,216 @@ +import { + TSJS_MESSAGE_PROTOCOL_V1, + type MessagingAdapter, + type MessagingPort, +} from '../adapters/messaging'; + +import type { ReservationRecognition, ReservationService } from './reservations'; + +const mapGetIntrinsic = Map.prototype.get; +const mapSetIntrinsic = Map.prototype.set; +const mapClearIntrinsic = Map.prototype.clear; +const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( + this: Map +) => number; +const mapValuesIntrinsic = Map.prototype.values; +const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( + this: IterableIterator +) => IteratorResult; +const jsonStringifyIntrinsic = JSON.stringify; +const objectFreezeIntrinsic = Object.freeze; + +interface PendingClaim { + readonly port: MessagingPort; + readonly source: object; +} + +export interface PucBridgeOptions { + readonly messaging: MessagingAdapter; + readonly reservations: Pick; +} + +export interface PucBridgeInventory { + readonly disposed: boolean; + readonly pendingClaims: number; +} + +export interface PucBridge { + dispose(): void; + snapshotInventoryForTest(): PucBridgeInventory; +} + +function mapValue(map: Map, key: Key): Value | undefined { + return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; +} + +function setMapValue(map: Map, key: Key, value: Value): void { + Reflect.apply(mapSetIntrinsic, map, [key, value]); +} + +function mapSize(map: Map): number { + return Reflect.apply(mapSizeGetter, map, []) as number; +} + +function snapshotMapValues(map: Map): readonly Value[] { + const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; + const values: Value[] = []; + while (true) { + const step = Reflect.apply(mapIteratorNextIntrinsic, iterator, []) as IteratorResult; + if (step.done) return values; + values[values.length] = step.value; + } +} + +function frozen(value: Value): Readonly { + return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; +} + +function recognizedReservation( + reservations: Pick, + reservationId: string +): ReservationRecognition | undefined { + try { + return reservations.recognize(reservationId); + } catch { + return undefined; + } +} + +function suppress(event: unknown): boolean { + try { + if (typeof event !== 'object' || event === null) return false; + const stop = Reflect.get(event, 'stopImmediatePropagation'); + if (typeof stop !== 'function') return false; + Reflect.apply(stop, event, []); + return true; + } catch { + return false; + } +} + +function eventData(event: unknown): unknown { + try { + return typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; + } catch { + return undefined; + } +} + +function eventSource(event: unknown): object | undefined { + try { + if (typeof event !== 'object' || event === null) return undefined; + const source = Reflect.get(event, 'source'); + return (typeof source === 'object' || typeof source === 'function') && source !== null + ? source + : undefined; + } catch { + return undefined; + } +} + +function refusedResponse(adId: string): string | undefined { + try { + const owner = Object.create(null) as Record; + owner['version'] = 1; + owner['status'] = TSJS_MESSAGE_PROTOCOL_V1.status.refused; + const response = Object.create(null) as Record; + response['message'] = TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse; + response['adId'] = adId; + response['rendererVersion'] = TSJS_MESSAGE_PROTOCOL_V1.rendererVersion; + response['tsOwner'] = owner; + const serialized = Reflect.apply(jsonStringifyIntrinsic, JSON, [response]) as unknown; + return typeof serialized === 'string' ? serialized : undefined; + } catch { + return undefined; + } +} + +function refuse(port: MessagingPort, adId: string): void { + try { + const response = refusedResponse(adId); + if (response !== undefined) port.post(response, []); + } catch { + // Refusal transport is best-effort; endpoint closure remains mandatory. + } finally { + try { + port.close(); + } catch { + // The adapter contains raw close failures, but keep this boundary fail-closed. + } + } +} + +/** + * Own the runtime-wide Universal Creative capture dispatcher. + * + * Request recognition deliberately precedes exact parsing and port inspection so + * malformed or replayed TS capabilities cannot fall through to native Prebid. + */ +export function createPucBridge(options: PucBridgeOptions): PucBridge { + const messaging = options.messaging; + const reservations = options.reservations; + const pendingClaims = new Map(); + let disposed = false; + + const dispatch = (event: MessageEvent): void => { + if (disposed) return; + const data = eventData(event); + const routing = messaging.inspectGlobalMessage(data); + if ( + routing?.message !== TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest || + routing.adId === undefined + ) { + return; + } + + const recognition = recognizedReservation(reservations, routing.adId); + if (recognition?.recognized !== true) return; + if (!suppress(event)) return; + + const exact = messaging.parseProtocolMessage('prebidRequest', data); + const ports = messaging.extractTransferredPorts(event, 1); + const port = ports?.[0]; + if (!port) return; + if (exact === undefined || recognition.state !== 'renderable') { + refuse(port, routing.adId); + return; + } + + const source = eventSource(event); + if (source === undefined || mapValue(pendingClaims, routing.adId) !== undefined) { + refuse(port, routing.adId); + return; + } + + setMapValue(pendingClaims, routing.adId, frozen({ port, source })); + }; + + const uninstall = messaging.installCaptureListener(dispatch); + + const bridge: PucBridge = { + dispose(): void { + if (disposed) return; + disposed = true; + try { + uninstall(); + } catch { + // Listener removal is already contained by the adapter. + } + const claims = snapshotMapValues(pendingClaims); + for (let index = 0; index < claims.length; index += 1) { + const claim = claims[index]; + if (!claim) continue; + try { + claim.port.close(); + } catch { + // Endpoint cleanup is exact-once at the adapter facade. + } + } + Reflect.apply(mapClearIntrinsic, pendingClaims, []); + }, + snapshotInventoryForTest(): PucBridgeInventory { + return frozen({ disposed, pendingClaims: mapSize(pendingClaims) }); + }, + }; + return frozen(bridge); +} diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index bc39cea2e..3d4c02474 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -764,6 +764,79 @@ describe('browser messaging adapter', () => { } }); + it('inspects only own routing data before exact global-message parsing', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const json = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + adServerDomain: 'ads.example.com', + ignored: { renderer: '' }, + }); + const object = Object.assign(Object.create(null), { + message: 'TS Render Owner Register', + adId: 'r1_abcdefghijklmnopqrstuv', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + ignored: true, + }); + + const inspectedJson = adapter.inspectGlobalMessage(json); + const inspectedObject = adapter.inspectGlobalMessage(object); + + expect(inspectedJson).toEqual({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + }); + expect(inspectedObject).toEqual({ + message: 'TS Render Owner Register', + adId: 'r1_abcdefghijklmnopqrstuv', + lifecycleTicket: 't1_abcdefghijklmnopqrstuv', + }); + expect(Object.isFrozen(inspectedJson)).toBe(true); + expect(Object.isFrozen(inspectedObject)).toBe(true); + }); + + it('inspects global routing data without invoking accessors or inherited properties', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const getter = vi.fn(() => 'Prebid Request'); + const accessor = Object.create(null) as Record; + Object.defineProperty(accessor, 'message', { get: getter, enumerable: true }); + Object.defineProperty(accessor, 'adId', { + value: 'r1_abcdefghijklmnopqrstuv', + enumerable: true, + }); + const inherited = Object.assign(Object.create({ message: 'Prebid Request' }), { + adId: 'r1_abcdefghijklmnopqrstuv', + }); + const throwingProxy = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('prototype trap'); + }, + } + ); + + expect(adapter.inspectGlobalMessage(accessor)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(inherited)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(throwingProxy)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it('rejects malformed, duplicate-key, and oversized routing JSON during inspection', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const duplicate = '{"message":"Prebid Request","adId":"first","adId":"second","ignored":true}'; + const oversized = JSON.stringify({ + message: 'Prebid Request', + adId: 'r1_abcdefghijklmnopqrstuv', + ignored: 'é'.repeat(2_100), + }); + + expect(adapter.inspectGlobalMessage('{')).toBeUndefined(); + expect(adapter.inspectGlobalMessage(duplicate)).toBeUndefined(); + expect(adapter.inspectGlobalMessage(oversized)).toBeUndefined(); + expect(adapter.inspectGlobalMessage({ adId: 'r1_abcdefghijklmnopqrstuv' })).toBeUndefined(); + }); + it('does not invoke accessors while rejecting an exact-shape candidate', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const getter = vi.fn(() => 'TS Owner Inserted'); diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts new file mode 100644 index 000000000..ea171ba85 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { createPucBridge } from '../../src/services/puc_bridge'; +import type { ReservationRecognition } from '../../src/services/reservations'; + +const RESERVATION_ID = 'r1_abcdefghijklmnopqrstuv'; + +function createPort() { + return { + addEventListener: vi.fn(), + close: vi.fn(), + postMessage: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + }; +} + +function exactRequest(adId = RESERVATION_ID): string { + return JSON.stringify({ + message: 'Prebid Request', + adId, + adServerDomain: 'ads.example.com', + }); +} + +function createHarness(recognize: (reservationId: unknown) => ReservationRecognition) { + let listener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { + listener = next; + } + ), + removeEventListener: vi.fn(), + }; + const bridge = createPucBridge({ + messaging: createBrowserMessagingAdapter(target), + reservations: { recognize }, + }); + const dispatch = (event: Record): void => { + if (!listener) throw new Error('Expected the capture listener to be installed synchronously'); + listener(event as unknown as MessageEvent); + }; + return { bridge, dispatch, target }; +} + +describe('Universal Creative bridge dispatcher', () => { + it('installs one capture listener synchronously and removes only that listener on disposal', () => { + const harness = createHarness(() => ({ recognized: false })); + + expect(harness.target.addEventListener).toHaveBeenCalledOnce(); + expect(harness.target.addEventListener.mock.calls[0]?.[0]).toBe('message'); + expect(harness.target.addEventListener.mock.calls[0]?.[2]).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + disposed: false, + pendingClaims: 0, + }); + + harness.bridge.dispose(); + harness.bridge.dispose(); + expect(harness.target.removeEventListener).toHaveBeenCalledOnce(); + expect(harness.target.removeEventListener.mock.calls[0]?.[0]).toBe('message'); + expect(harness.target.removeEventListener.mock.calls[0]?.[2]).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + disposed: true, + pendingClaims: 0, + }); + }); + + it('leaves native Prebid identifiers untouched before port or source inspection', () => { + const recognize = vi.fn((): ReservationRecognition => ({ recognized: false })); + const harness = createHarness(recognize); + const stopImmediatePropagation = vi.fn(); + const ports = vi.fn(() => { + throw new Error('native ports must not be read'); + }); + const source = vi.fn(() => { + throw new Error('native source must not be read'); + }); + + harness.dispatch({ + data: exactRequest('native-prebid-id'), + stopImmediatePropagation, + get ports() { + return ports(); + }, + get source() { + return source(); + }, + }); + + expect(recognize).toHaveBeenCalledWith('native-prebid-id'); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(ports).not.toHaveBeenCalled(); + expect(source).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it.each([ + ['extended object', { message: 'Prebid Request', adId: RESERVATION_ID, extra: true }], + [ + 'extended JSON', + JSON.stringify({ message: 'Prebid Request', adId: RESERVATION_ID, extra: true }), + ], + ])('suppresses and generically refuses a recognized %s before exact parsing', (_label, data) => { + const order: string[] = []; + const harness = createHarness((reservationId) => { + order.push(`lookup:${String(reservationId)}`); + return { recognized: true, state: 'renderable', expiresAt: 1_000 }; + }); + const port = createPort(); + + harness.dispatch({ + data, + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(() => order.push('stop')), + }); + + expect(order).toEqual([`lookup:${RESERVATION_ID}`, 'stop']); + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '3', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(port.close).toHaveBeenCalledOnce(); + }); + + it('suppresses recognized requests with the wrong port count and closes every available port', () => { + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const second = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactRequest(), + ports: [first, second], + source: Object.freeze({}), + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(first.postMessage).not.toHaveBeenCalled(); + expect(second.postMessage).not.toHaveBeenCalled(); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it('buffers only the first exact live claim and generically refuses a duplicate', () => { + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 1_000, + })); + const first = createPort(); + const duplicate = createPort(); + const source = Object.freeze({ frame: 'authoritative' }); + + harness.dispatch({ + data: exactRequest(), + ports: [first], + source, + stopImmediatePropagation: vi.fn(), + }); + + expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(1); + + harness.dispatch({ + data: exactRequest(), + ports: [duplicate], + source: Object.freeze({ frame: 'duplicate' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(duplicate.postMessage).toHaveBeenCalledOnce(); + expect(duplicate.close).toHaveBeenCalledOnce(); + expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(1); + + harness.bridge.dispose(); + expect(first.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + }); + + it.each(['consumed', 'disposed', 'awaiting_prebid_selection'] as const)( + 'suppresses and refuses a recognized non-renderable %s reservation', + (state) => { + const harness = createHarness(() => ({ recognized: true, state, expiresAt: 1_000 })); + const port = createPort(); + + harness.dispatch({ + data: exactRequest(), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); + } + ); +}); From f35248f2cae7c3d397db72a5c67e716092995744 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:19:41 -0700 Subject: [PATCH 299/494] Harden cache rendering boundaries --- .../lib/src/composition/browser.ts | 17 +- .../lib/src/services/render.ts | 623 ++++++++++++----- .../lib/test/composition/browser.test.ts | 61 ++ .../lib/test/core/config.test.ts | 35 + .../lib/test/services/render.test.ts | 636 +++++++++++++++++- .../lib/test/services/reservations.test.ts | 2 +- 6 files changed, 1165 insertions(+), 209 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index e488e8093..eef7e1967 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -234,7 +234,22 @@ export function createTestBrowserRuntimeComposition( } }; const renderDirectCache = (attempt: RenderAttempt, container: HTMLElement): boolean => { - if (!cachePolicy || typeof fetchCache !== 'function') return false; + if (!cachePolicy) { + try { + attempt.fail('descriptor_invalid'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + if (typeof fetchCache !== 'function') { + try { + attempt.fail('cache_network_error'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } try { return renderDirectCacheAttempt({ attempt, diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index d83e81d98..f24ad403d 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -24,10 +24,19 @@ const directAdmOwnerDocumentGetter = ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'ownerDocument')?.get; const objectFreezeIntrinsic = Object.freeze; +const objectIsFrozenIntrinsic = Object.isFrozen; +const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectGetOwnPropertySymbolsIntrinsic = Object.getOwnPropertySymbols; +const objectGetOwnPropertyDescriptorIntrinsic = Object.getOwnPropertyDescriptor; +const objectCreateIntrinsic = Object.create; const objectToStringIntrinsic = Object.prototype.toString; +const objectHasOwnIntrinsic = Object.prototype.hasOwnProperty; +const arrayIsArrayIntrinsic = Array.isArray; const arrayIncludesIntrinsic = Array.prototype.includes; const arrayPushIntrinsic = Array.prototype.push; const arraySliceIntrinsic = Array.prototype.slice; +const arraySortIntrinsic = Array.prototype.sort; const arraySpliceIntrinsic = Array.prototype.splice; const mapGetIntrinsic = Map.prototype.get; const mapSetIntrinsic = Map.prototype.set; @@ -63,13 +72,71 @@ const weakSetAddIntrinsic = WeakSet.prototype.add; const weakSetHasIntrinsic = WeakSet.prototype.has; const weakSetDeleteIntrinsic = WeakSet.prototype.delete; const promiseThenIntrinsic = Promise.prototype.then; +const numberIsFiniteIntrinsic = Number.isFinite; +const numberIsIntegerIntrinsic = Number.isInteger; +const regexpTestIntrinsic = RegExp.prototype.test; +const stringCharCodeAtIntrinsic = String.prototype.charCodeAt; const stringIndexOfIntrinsic = String.prototype.indexOf; const stringSliceIntrinsic = String.prototype.slice; +const stringTrimIntrinsic = String.prototype.trim; const stringIntrinsic = String; const jsonParseIntrinsic = JSON.parse; +const urlIntrinsic = URL; +type UrlTextProperty = + | 'href' + | 'protocol' + | 'hostname' + | 'username' + | 'password' + | 'origin' + | 'port' + | 'pathname' + | 'search' + | 'hash'; + +function captureUrlGetter(name: UrlTextProperty): ((this: URL) => string) | undefined { + let prototype: object | null = URL.prototype; + while (prototype) { + const getter = Object.getOwnPropertyDescriptor(prototype, name)?.get; + if (typeof getter === 'function') return getter as (this: URL) => string; + prototype = Object.getPrototypeOf(prototype) as object | null; + } + return undefined; +} + +const urlGetters: Readonly string) | undefined>> = + Object.freeze({ + href: captureUrlGetter('href'), + protocol: captureUrlGetter('protocol'), + hostname: captureUrlGetter('hostname'), + username: captureUrlGetter('username'), + password: captureUrlGetter('password'), + origin: captureUrlGetter('origin'), + port: captureUrlGetter('port'), + pathname: captureUrlGetter('pathname'), + search: captureUrlGetter('search'), + hash: captureUrlGetter('hash'), + }); +const encodeURIComponentIntrinsic = encodeURIComponent; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; +const fatalTextDecoder = new TextDecoder('utf-8', { fatal: true }); +const textDecoderDecodeIntrinsic = TextDecoder.prototype.decode; +const abortControllerIntrinsic = AbortController; +const abortControllerAbortIntrinsic = AbortController.prototype.abort; +const abortControllerSignalGetter = Object.getOwnPropertyDescriptor( + AbortController.prototype, + 'signal' +)?.get as (this: AbortController) => AbortSignal; +const abortSignalAbortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted') + ?.get as (this: AbortSignal) => boolean; const artifactDisposals = new WeakMap(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); +const cacheAttemptControls = new WeakMap< + object, + Readonly<{ begin: () => boolean; complete: () => boolean }> +>(); const ignoreAsyncDisposal = (): void => undefined; function frozen(value: Value): Readonly { @@ -80,6 +147,62 @@ function arrayPush(array: Value[], value: Value): number { return Reflect.apply(arrayPushIntrinsic, array, [value]) as number; } +function utf8Length(value: string): number { + return (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .byteLength; +} + +function isFiniteNumber(value: number): boolean { + return reflectApplyIntrinsic(numberIsFiniteIntrinsic, Number, [value]) as boolean; +} + +function isInteger(value: number): boolean { + return reflectApplyIntrinsic(numberIsIntegerIntrinsic, Number, [value]) as boolean; +} + +function regexpTest(pattern: RegExp, value: string): boolean { + return reflectApplyIntrinsic(regexpTestIntrinsic, pattern, [value]) as boolean; +} + +function hasOwn(value: object, name: PropertyKey): boolean { + return reflectApplyIntrinsic(objectHasOwnIntrinsic, value, [name]) as boolean; +} + +function objectIsFrozen(value: object): boolean { + return reflectApplyIntrinsic(objectIsFrozenIntrinsic, Object, [value]) as boolean; +} + +function objectPrototype(value: object): object | null { + return reflectApplyIntrinsic(objectGetPrototypeOfIntrinsic, Object, [value]) as object | null; +} + +function ownPropertyNames(value: object): string[] { + return reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]; +} + +function ownPropertySymbols(value: object): symbol[] { + return reflectApplyIntrinsic(objectGetOwnPropertySymbolsIntrinsic, Object, [value]) as symbol[]; +} + +function ownPropertyDescriptor(value: object, name: PropertyKey): PropertyDescriptor | undefined { + return reflectApplyIntrinsic(objectGetOwnPropertyDescriptorIntrinsic, Object, [value, name]) as + PropertyDescriptor | undefined; +} + +function isArray(value: unknown): value is unknown[] { + return reflectApplyIntrinsic(arrayIsArrayIntrinsic, Array, [value]) as boolean; +} + +function sortedStrings(values: string[]): string[] { + return reflectApplyIntrinsic(arraySortIntrinsic, values, []) as string[]; +} + +function urlPart(url: URL, name: UrlTextProperty): string { + const getter = urlGetters[name]; + if (typeof getter !== 'function') throw new TypeError('missing URL accessor'); + return reflectApplyIntrinsic(getter, url, []) as string; +} + function isUint8Array(value: unknown): value is Uint8Array { return ( (typeof value === 'object' || typeof value === 'function') && @@ -377,8 +500,6 @@ export interface RenderAttempt { readonly beginGamClaim: () => boolean; readonly ownerClaimed: () => boolean; readonly ownerRegistered: () => boolean; - readonly beginCacheFetch: () => boolean; - readonly cacheFetchCompleted: () => boolean; readonly beginDirect: () => boolean; readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; @@ -407,12 +528,35 @@ interface CacheFetchReader { interface CacheFetchResponse { readonly body: Readonly<{ getReader: () => CacheFetchReader }> | null; readonly ok: boolean; - readonly type?: Response['type']; + readonly type: Response['type']; +} + +type CacheFetcher = (input: string, init: RequestInit) => Promise; + +export interface CacheAdmSource { + readonly adm: string; + readonly height: number; + readonly type: 'adm'; + readonly version: 1; + readonly width: number; +} + +export interface CacheFetchPolicy { + readonly baseUrl: string; + readonly version: 1; +} + +export interface CacheAdmResolutionOptions { + readonly attempt: RenderAttempt; + readonly cachePolicy: Readonly; + readonly fetcher: CacheFetcher; + readonly onResolved: (source: Readonly) => boolean; + readonly publisherOrigin: string; } export interface DirectCacheAttemptOptions extends DirectAdmAttemptOptions { - readonly cachePolicy: Readonly<{ version: 1; baseUrl: string }>; - readonly fetcher: (input: string, init: RequestInit) => Promise; + readonly cachePolicy: Readonly; + readonly fetcher: CacheFetcher; } export interface DirectAdmIframeHandle { @@ -528,9 +672,9 @@ function validOutcome(value: unknown): value is RenderOutcome { if ( typeof value !== 'object' || value === null || - !Object.isFrozen(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 + !objectIsFrozen(value) || + objectPrototype(value) !== Object.prototype || + ownPropertySymbols(value).length !== 0 ) { return false; } @@ -570,7 +714,7 @@ function validArtifact( try { if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false; if (!Object.isFrozen(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; - const names = Object.getOwnPropertyNames(value).sort(); + const names = sortedStrings(ownPropertyNames(value)); if ( names.length !== 5 || names[0] !== 'attemptId' || @@ -1080,20 +1224,20 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp if ( (typeof context !== 'object' && typeof context !== 'function') || context === null || - !Object.isFrozen(context) || - Object.getPrototypeOf(context) !== Object.prototype || - Object.getOwnPropertyNames(context).length !== 1 || - Object.getOwnPropertySymbols(context).length !== 0 + !objectIsFrozen(context) || + objectPrototype(context) !== Object.prototype || + ownPropertyNames(context).length !== 1 || + ownPropertySymbols(context).length !== 0 ) { return false; } - const selectedCpm = Object.getOwnPropertyDescriptor(context, 'selectedCpm'); + const selectedCpm = ownPropertyDescriptor(context, 'selectedCpm'); return ( !!selectedCpm && 'value' in selectedCpm && selectedCpm.enumerable === true && typeof selectedCpm.value === 'number' && - Number.isFinite(selectedCpm.value) && + isFiniteNumber(selectedCpm.value) && selectedCpm.value >= 0 ); } catch { @@ -1360,21 +1504,6 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp ? enter(['waiting_for_gam_and_claim'], 'waiting_for_owner') : false, ownerRegistered: () => enter(['waiting_for_owner'], 'waiting_for_insertion'), - beginCacheFetch, - cacheFetchCompleted: () => { - if ( - admittedRenderSource?.type !== 'cache' || - !admittedWinnerContext || - outcome !== undefined || - (state !== 'rendering_direct' && state !== 'waiting_for_insertion') || - deadlineState !== state || - !ownerIsCurrent() - ) { - return false; - } - clearDeadline(); - return true; - }, beginDirect: () => (admittedRenderSource?.type === 'aps' || admittedRenderSource?.type === 'adm') && admittedWinnerContext @@ -1507,17 +1636,37 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp disposeRejectedOwner(); return frozen({ ok: false, reason: 'stale_owner' }); } - weakSetAdd(renderAttempts, lifecycle); - return frozen({ ok: true, value: frozen(lifecycle) }); + const exposedLifecycle = frozen(lifecycle); + weakSetAdd(renderAttempts, exposedLifecycle); + weakMapSet( + cacheAttemptControls, + exposedLifecycle, + frozen({ + begin: beginCacheFetch, + complete: () => { + const cacheState = state; + if ( + admittedRenderSource?.type !== 'cache' || + !admittedWinnerContext || + outcome !== undefined || + (cacheState !== 'rendering_direct' && cacheState !== 'waiting_for_insertion') || + deadlineState !== cacheState || + !ownerIsCurrent() + ) { + return false; + } + clearDeadline(); + if (cacheState === 'waiting_for_insertion' && outcome === undefined) { + armDeadline(cacheState); + } + return outcome === undefined && state === cacheState && ownerIsCurrent(); + }, + }) + ); + return frozen({ ok: true, value: exposedLifecycle }); } -type DirectAdmSource = Readonly<{ - adm: string; - height: number; - type: 'adm'; - version: 1; - width: number; -}>; +type DirectAdmSource = Readonly; type DirectCacheSource = Readonly<{ cacheId: string; @@ -1540,19 +1689,22 @@ function exactFrozenDataRecord( if ( typeof value !== 'object' || value === null || - !Object.isFrozen(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 + !objectIsFrozen(value) || + objectPrototype(value) !== Object.prototype || + ownPropertySymbols(value).length !== 0 ) { return undefined; } - const names = Object.getOwnPropertyNames(value).sort(); + const names = sortedStrings(ownPropertyNames(value)); if (names.length !== expectedNames.length) return undefined; - const fields = Object.create(null) as Record; + const fields = reflectApplyIntrinsic(objectCreateIntrinsic, Object, [null]) as Record< + string, + unknown + >; for (let index = 0; index < expectedNames.length; index += 1) { const name = expectedNames[index]; if (!name || names[index] !== name) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(value, name); + const descriptor = ownPropertyDescriptor(value, name); if ( !descriptor || !('value' in descriptor) || @@ -1579,30 +1731,32 @@ function readCachePolicyBase(value: unknown): URL | undefined { fields['version'] !== 1 || typeof baseUrl !== 'string' || baseUrl.length === 0 || - new TextEncoder().encode(baseUrl).byteLength > MAX_CACHE_URL_BYTES + utf8Length(baseUrl) > MAX_CACHE_URL_BYTES ) { return undefined; } for (let index = 0; index < baseUrl.length; index += 1) { - const code = baseUrl.charCodeAt(index); + const code = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, baseUrl, [index]) as number; if (code <= 0x1f || code === 0x7f) return undefined; if (code >= 0xd800 && code <= 0xdbff) { - const next = baseUrl.charCodeAt(index + 1); + const next = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, baseUrl, [ + index + 1, + ]) as number; if (next < 0xdc00 || next > 0xdfff) return undefined; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { return undefined; } } - const base = new URL(baseUrl); + const base = Reflect.construct(urlIntrinsic, [baseUrl]) as URL; if ( - base.protocol !== 'https:' || - base.hostname === '' || - base.username !== '' || - base.password !== '' || - base.search !== '' || - base.hash !== '' || - base.pathname === '/' + urlPart(base, 'protocol') !== 'https:' || + urlPart(base, 'hostname') === '' || + urlPart(base, 'username') !== '' || + urlPart(base, 'password') !== '' || + urlPart(base, 'search') !== '' || + urlPart(base, 'hash') !== '' || + urlPart(base, 'pathname') === '/' ) { return undefined; } @@ -1632,16 +1786,16 @@ function readDirectCacheSource( fields['type'] !== 'cache' || fields['version'] !== 1 || typeof fields['cacheId'] !== 'string' || - !CACHE_ID.test(fields['cacheId']) || + !regexpTest(CACHE_ID, fields['cacheId']) || typeof fields['fetchUrl'] !== 'string' || fields['fetchUrl'].length === 0 || - new TextEncoder().encode(fields['fetchUrl']).byteLength > MAX_CACHE_URL_BYTES || + utf8Length(fields['fetchUrl']) > MAX_CACHE_URL_BYTES || typeof fields['width'] !== 'number' || - !Number.isInteger(fields['width']) || + !isInteger(fields['width']) || fields['width'] < 1 || fields['width'] > 4096 || typeof fields['height'] !== 'number' || - !Number.isInteger(fields['height']) || + !isInteger(fields['height']) || fields['height'] < 1 || fields['height'] > 4096 ) { @@ -1649,32 +1803,36 @@ function readDirectCacheSource( } const sourceFetchUrl = fields['fetchUrl'] as string; for (let index = 0; index < sourceFetchUrl.length; index += 1) { - const code = sourceFetchUrl.charCodeAt(index); + const code = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, sourceFetchUrl, [ + index, + ]) as number; if (code <= 0x1f || code === 0x7f) return undefined; if (code >= 0xd800 && code <= 0xdbff) { - const next = sourceFetchUrl.charCodeAt(index + 1); + const next = reflectApplyIntrinsic(stringCharCodeAtIntrinsic, sourceFetchUrl, [ + index + 1, + ]) as number; if (next < 0xdc00 || next > 0xdfff) return undefined; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { return undefined; } } - const fetchUrl = new URL(sourceFetchUrl); - const expected = new URL(base.href); - expected.search = `?uuid=${encodeURIComponent(fields['cacheId'])}`; + const fetchUrl = Reflect.construct(urlIntrinsic, [sourceFetchUrl]) as URL; + const canonicalSearch = `?uuid=${ + reflectApplyIntrinsic(encodeURIComponentIntrinsic, undefined, [fields['cacheId']]) as string + }`; + const expectedHref = `${urlPart(base, 'href')}${canonicalSearch}`; if ( - fetchUrl.protocol !== 'https:' || - fetchUrl.username !== '' || - fetchUrl.password !== '' || - fetchUrl.hash !== '' || - fetchUrl.origin !== base.origin || - fetchUrl.port !== base.port || - fetchUrl.pathname !== base.pathname || - [...fetchUrl.searchParams.keys()].length !== 1 || - fetchUrl.searchParams.get('uuid') !== fields['cacheId'] || - fetchUrl.search !== `?uuid=${encodeURIComponent(fields['cacheId'])}` || - fetchUrl.href !== fields['fetchUrl'] || - fetchUrl.href !== expected.href + urlPart(fetchUrl, 'protocol') !== 'https:' || + urlPart(fetchUrl, 'username') !== '' || + urlPart(fetchUrl, 'password') !== '' || + urlPart(fetchUrl, 'hash') !== '' || + urlPart(fetchUrl, 'origin') !== urlPart(base, 'origin') || + urlPart(fetchUrl, 'port') !== urlPart(base, 'port') || + urlPart(fetchUrl, 'pathname') !== urlPart(base, 'pathname') || + urlPart(fetchUrl, 'search') !== canonicalSearch || + urlPart(fetchUrl, 'href') !== fields['fetchUrl'] || + urlPart(fetchUrl, 'href') !== expectedHref ) { return undefined; } @@ -1687,7 +1845,7 @@ function readDirectCacheSource( function readSelectedCpm(value: unknown): number | undefined { const fields = exactFrozenDataRecord(value, ['selectedCpm']); const selectedCpm = fields?.['selectedCpm']; - return typeof selectedCpm === 'number' && Number.isFinite(selectedCpm) && selectedCpm >= 0 + return typeof selectedCpm === 'number' && isFiniteNumber(selectedCpm) && selectedCpm >= 0 ? selectedCpm : undefined; } @@ -1718,46 +1876,47 @@ function parseCacheAdm( if ( typeof value !== 'object' || value === null || - Array.isArray(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 + isArray(value) || + objectPrototype(value) !== Object.prototype || + ownPropertySymbols(value).length !== 0 ) { return undefined; } const record = value as Record; - const names = Object.getOwnPropertyNames(record); + const names = ownPropertyNames(record); for (let index = 0; index < names.length; index += 1) { const name = names[index]; if (!name) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(record, name); + const descriptor = ownPropertyDescriptor(record, name); if (!descriptor || !('value' in descriptor) || descriptor.enumerable !== true) { return undefined; } } - const hasOwn = (name: string): boolean => Object.prototype.hasOwnProperty.call(record, name); - if (hasOwn('width') || hasOwn('height')) return undefined; - const admDescriptor = Object.getOwnPropertyDescriptor(record, 'adm'); + const recordHasOwn = (name: string): boolean => hasOwn(record, name); + if (recordHasOwn('width') || recordHasOwn('height')) return undefined; + const admDescriptor = ownPropertyDescriptor(record, 'adm'); if ( !admDescriptor || !('value' in admDescriptor) || typeof admDescriptor.value !== 'string' || - admDescriptor.value.trim().length === 0 || - new TextEncoder().encode(admDescriptor.value).byteLength > MAX_CACHE_BODY_BYTES + (reflectApplyIntrinsic(stringTrimIntrinsic, admDescriptor.value, []) as string).length === + 0 || + utf8Length(admDescriptor.value) > MAX_CACHE_BODY_BYTES ) { return undefined; } - const hasWidth = hasOwn('w'); - const hasHeight = hasOwn('h'); + const hasWidth = recordHasOwn('w'); + const hasHeight = recordHasOwn('h'); if (hasWidth !== hasHeight) return undefined; if ( hasWidth && (typeof record['w'] !== 'number' || - !Number.isInteger(record['w']) || + !isInteger(record['w']) || record['w'] < 1 || record['w'] > 4096 || record['w'] !== source.width || typeof record['h'] !== 'number' || - !Number.isInteger(record['h']) || + !isInteger(record['h']) || record['h'] < 1 || record['h'] > 4096 || record['h'] !== source.height) @@ -1765,15 +1924,15 @@ function parseCacheAdm( return undefined; } if ( - hasOwn('price') && + recordHasOwn('price') && (typeof record['price'] !== 'number' || - !Number.isFinite(record['price']) || + !isFiniteNumber(record['price']) || record['price'] < 0) ) { return undefined; } const adm = expandAuctionPrice(admDescriptor.value, selectedCpm); - if (new TextEncoder().encode(adm).byteLength > MAX_CACHE_BODY_BYTES) return undefined; + if (utf8Length(adm) > MAX_CACHE_BODY_BYTES) return undefined; return frozen({ adm, height: source.height, @@ -1786,41 +1945,50 @@ function parseCacheAdm( } } -async function readCacheBody(response: CacheFetchResponse): Promise { +interface CacheBodyReadHooks { + readonly active: () => boolean; + readonly retain: ( + reader: CacheFetchReader, + cancel: NonNullable + ) => boolean; + readonly cancel: () => void; + readonly release: (reader: CacheFetchReader) => void; +} + +async function readCacheBody( + response: CacheFetchResponse, + hooks: CacheBodyReadHooks +): Promise { let reader: CacheFetchReader | undefined; - let cancel: CacheFetchReader['cancel']; let releaseLock: (() => void) | undefined; try { - if ( - response.type === 'error' || - response.type === 'opaque' || - response.type === 'opaqueredirect' - ) { - return frozen({ ok: false, reason: 'cache_network_error' }); - } if (!response.ok) return frozen({ ok: false, reason: 'cache_invalid_response' }); if (!response.body) return frozen({ ok: true, text: '' }); reader = response.body.getReader(); - cancel = reader.cancel; + const cancel = reader.cancel; + const read = reader.read; releaseLock = reader.releaseLock; - if (typeof reader.read !== 'function' || typeof cancel !== 'function') { + if ( + typeof read !== 'function' || + typeof cancel !== 'function' || + !hooks.active() || + !hooks.retain(reader, cancel) + ) { return frozen({ ok: false, reason: 'cache_network_error' }); } const chunks: Uint8Array[] = []; let total = 0; while (true) { - const step = await reader.read(); + if (!hooks.active()) return frozen({ ok: false, reason: 'cache_network_error' }); + const step = await reflectApplyIntrinsic(read, reader, []); + if (!hooks.active()) return frozen({ ok: false, reason: 'cache_network_error' }); if (step.done) break; if (!isUint8Array(step.value)) { return frozen({ ok: false, reason: 'cache_network_error' }); } total += step.value.byteLength; if (total > MAX_CACHE_BODY_BYTES) { - try { - await cancel.call(reader); - } catch { - // The byte limit is authoritative even if stream cancellation is hostile. - } + hooks.cancel(); return frozen({ ok: false, reason: 'cache_invalid_response' }); } arrayPush(chunks, step.value); @@ -1833,16 +2001,23 @@ async function readCacheBody(response: CacheFetchResponse): Promise; + const fields = reflectApplyIntrinsic(objectCreateIntrinsic, Object, [null]) as Record< + string, + unknown + >; for (const name of expected) { - const descriptor = Object.getOwnPropertyDescriptor(value, name); + const descriptor = ownPropertyDescriptor(value, name); if ( !descriptor || !('value' in descriptor) || @@ -1885,14 +2063,14 @@ function readDirectAdmSource(value: unknown): DirectAdmSource | undefined { fields['type'] !== 'adm' || fields['version'] !== 1 || typeof fields['adm'] !== 'string' || - fields['adm'].trim().length === 0 || - new TextEncoder().encode(fields['adm']).byteLength > 512 * 1024 || + (reflectApplyIntrinsic(stringTrimIntrinsic, fields['adm'], []) as string).length === 0 || + utf8Length(fields['adm']) > MAX_CACHE_BODY_BYTES || typeof fields['width'] !== 'number' || - !Number.isInteger(fields['width']) || + !isInteger(fields['width']) || fields['width'] < 1 || fields['width'] > 4096 || typeof fields['height'] !== 'number' || - !Number.isInteger(fields['height']) || + !isInteger(fields['height']) || fields['height'] < 1 || fields['height'] > 4096 ) { @@ -1943,12 +2121,8 @@ function renderAdmAttempt( return false; } if (admittedCacheAdm === undefined && !attempt.beginDirect()) return false; - let artifactKind: CommittedRenderArtifact['kind']; try { - const pathState = attempt.snapshot().state; - if (pathState === 'waiting_for_insertion') artifactKind = 'puc'; - else if (pathState === 'rendering_direct') artifactKind = 'direct_iframe'; - else return false; + if (attempt.snapshot().state !== 'rendering_direct') return false; } catch { attempt.fail('internal_error'); return false; @@ -2045,7 +2219,7 @@ function renderAdmAttempt( } const artifact = frozen({ - kind: artifactKind, + kind: 'direct_iframe', attemptId: attempt.id, slot: attempt.slot, navigationGeneration: attempt.navigationGeneration, @@ -2092,20 +2266,18 @@ export function renderDirectAdmAttempt(options: DirectAdmAttemptOptions): boolea return renderAdmAttempt(options); } -/** Fetch one admitted cache source, then enter the exact shared direct-ADM lifecycle. */ -export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): boolean { +/** Resolve one admitted cache source without assuming direct or PUC DOM ownership. */ +export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): boolean { let attempt: RenderAttempt; - let cachePolicy: DirectCacheAttemptOptions['cachePolicy']; - let container: HTMLElement; - let fetchCache: DirectCacheAttemptOptions['fetcher']; - let prepareIframe: DirectAdmIframeConstructor; + let cachePolicy: CacheAdmResolutionOptions['cachePolicy']; + let fetchCache: CacheAdmResolutionOptions['fetcher']; + let onResolved: CacheAdmResolutionOptions['onResolved']; let publisherOrigin: string; try { attempt = options.attempt; cachePolicy = options.cachePolicy; - container = options.container; fetchCache = options.fetcher; - prepareIframe = options.prepareIframe; + onResolved = options.onResolved; publisherOrigin = options.publisherOrigin; } catch { return false; @@ -2113,48 +2285,89 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo if ( !weakSetHas(renderAttempts, attempt) || typeof fetchCache !== 'function' || - typeof prepareIframe !== 'function' + typeof onResolved !== 'function' ) { return false; } - - let exactDocumentOrigin: boolean; - try { - exactDocumentOrigin = - !!directAdmDocument && - typeof directAdmOwnerDocumentGetter === 'function' && - reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && - directAdmDocument.defaultView?.location.origin === publisherOrigin; - } catch { - exactDocumentOrigin = false; - } - if (!exactDocumentOrigin) { - attempt.fail('winner_not_renderable'); - return false; - } + const cacheControls = weakMapGet(cacheAttemptControls, attempt); + if (!cacheControls) return false; const source = readDirectCacheSource(attempt.renderSource, cachePolicy); const winnerContext = attempt.winnerContext; const selectedCpm = readSelectedCpm(winnerContext); - if (!source || selectedCpm === undefined) { + let cacheOrigin: string | undefined; + try { + cacheOrigin = source + ? urlPart(Reflect.construct(urlIntrinsic, [source.fetchUrl]) as URL, 'origin') + : undefined; + } catch { + cacheOrigin = undefined; + } + if (!source || selectedCpm === undefined || cacheOrigin === undefined) { attempt.fail('descriptor_invalid'); return false; } - if (!attempt.beginCacheFetch()) return false; + if (reflectApplyIntrinsic(cacheControls.begin, cacheControls, []) !== true) return false; let controller: AbortController; + let signal: AbortSignal; try { - controller = new AbortController(); + controller = Reflect.construct(abortControllerIntrinsic, []) as AbortController; + signal = reflectApplyIntrinsic(abortControllerSignalGetter, controller, []) as AbortSignal; } catch { attempt.fail('cache_network_error'); return false; } let pending = true; + let activeReader: CacheFetchReader | undefined; + let activeReaderCancel: NonNullable | undefined; + let activeReaderCancelled = false; + const retainBodyReader = ( + reader: CacheFetchReader, + cancel: NonNullable + ): boolean => { + if (!pending || activeReader !== undefined) return false; + activeReader = reader; + activeReaderCancel = cancel; + activeReaderCancelled = false; + return true; + }; + const releaseBodyReader = (reader: CacheFetchReader): void => { + if (activeReader !== reader) return; + activeReader = undefined; + activeReaderCancel = undefined; + activeReaderCancelled = false; + }; + const cancelBodyReader = (): void => { + const reader = activeReader; + const cancel = activeReaderCancel; + if (!reader || !cancel || activeReaderCancelled) return; + activeReaderCancelled = true; + try { + const cancellation = reflectApplyIntrinsic(cancel, reader, []) as unknown; + if ( + (typeof cancellation === 'object' || typeof cancellation === 'function') && + cancellation !== null + ) { + try { + reflectApplyIntrinsic(promiseThenIntrinsic, cancellation, [ + ignoreAsyncDisposal, + ignoreAsyncDisposal, + ]); + } catch { + // Cancellation authority is exact-once even for a hostile promise boundary. + } + } + } catch { + // Terminal settlement remains authoritative if reader cancellation throws. + } + }; const abortFetch = (): void => { - if (controller.signal.aborted) return; + cancelBodyReader(); try { - controller.abort(); + if (reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) === true) return; + reflectApplyIntrinsic(abortControllerAbortIntrinsic, controller, []); } catch { // Abort is best-effort after the attempt has already settled. } @@ -2191,7 +2404,7 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo redirect: 'error', referrer: '', referrerPolicy: 'no-referrer', - signal: controller.signal, + signal, } satisfies RequestInit, ]) as Promise; } catch { @@ -2210,7 +2423,7 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo if (!pending) return; let response: CacheFetchResponse; let responseOk: boolean; - let responseType: Response['type'] | undefined; + let responseType: Response['type']; try { if ((typeof fetched !== 'object' && typeof fetched !== 'function') || fetched === null) { throw new TypeError('invalid cache response'); @@ -2218,16 +2431,15 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo response = fetched as CacheFetchResponse; responseOk = response.ok; responseType = response.type; - if (typeof responseOk !== 'boolean') throw new TypeError('invalid cache status'); + if (typeof responseOk !== 'boolean' || typeof responseType !== 'string') { + throw new TypeError('invalid cache response metadata'); + } } catch { failCache('cache_network_error'); return; } - if ( - responseType === 'error' || - responseType === 'opaque' || - responseType === 'opaqueredirect' - ) { + const expectedResponseType = cacheOrigin === publisherOrigin ? 'basic' : 'cors'; + if (responseType !== expectedResponseType) { failCache('cache_network_error'); return; } @@ -2235,13 +2447,18 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo failCache('cache_http_error'); return; } - const body = await readCacheBody(response); + const body = await readCacheBody(response, { + active: () => pending, + cancel: cancelBodyReader, + release: releaseBodyReader, + retain: retainBodyReader, + }); if (!pending) return; if (!body.ok) { failCache(body.reason); return; } - if (!attempt.cacheFetchCompleted()) { + if (reflectApplyIntrinsic(cacheControls.complete, cacheControls, []) !== true) { failCache('cache_network_error'); return; } @@ -2255,15 +2472,18 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo return; } pending = false; + let resolved: boolean; try { - if ( - !renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, admSource) && - attempt.snapshot().outcome === undefined - ) { - attempt.fail('internal_error'); - } + resolved = reflectApplyIntrinsic(onResolved, undefined, [admSource]) === true; } catch { - attempt.fail('internal_error'); + resolved = false; + } + if (!resolved) { + try { + if (attempt.snapshot().outcome === undefined) attempt.fail('internal_error'); + } catch { + // The terminal latch remains authoritative across a hostile consumer boundary. + } } }; const completion = complete(); @@ -2279,6 +2499,59 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo return true; } +/** Fetch and render one admitted direct cache source through the shared ADM constructor. */ +export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): boolean { + let attempt: RenderAttempt; + let cachePolicy: DirectCacheAttemptOptions['cachePolicy']; + let container: HTMLElement; + let fetcher: DirectCacheAttemptOptions['fetcher']; + let prepareIframe: DirectAdmIframeConstructor; + let publisherOrigin: string; + try { + attempt = options.attempt; + cachePolicy = options.cachePolicy; + container = options.container; + fetcher = options.fetcher; + prepareIframe = options.prepareIframe; + publisherOrigin = options.publisherOrigin; + } catch { + return false; + } + if (!weakSetHas(renderAttempts, attempt) || typeof prepareIframe !== 'function') return false; + + let created = false; + let exactDirectContainer: boolean; + try { + created = attempt.snapshot().state === 'created'; + exactDirectContainer = + created && + !!directAdmDocument && + typeof directAdmOwnerDocumentGetter === 'function' && + reflectApplyIntrinsic(directAdmOwnerDocumentGetter, container, []) === directAdmDocument && + directAdmDocument.defaultView?.location.origin === publisherOrigin; + } catch { + exactDirectContainer = false; + } + if (!created) return false; + if (!exactDirectContainer) { + try { + attempt.fail('winner_not_renderable'); + } catch { + // The exact direct-container boundary fails closed. + } + return false; + } + + return resolveCacheAdmAttempt({ + attempt, + cachePolicy, + fetcher, + onResolved: (source) => + renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, source), + publisherOrigin, + }); +} + interface RendererNonceBinding { readonly nonce: string; readonly attempt: RenderAttempt; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 7152c697e..485109a14 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -22,6 +22,7 @@ import { createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import type { RenderAttempt } from '../../src/services/render'; function createTarget() { return { @@ -654,6 +655,66 @@ describe('browser composition', () => { expect(composition.projectionSlotsForTest()).toEqual([]); }); + it('fails an admitted cache attempt when owner activation captured no fetch authority', async () => { + const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + value: undefined, + writable: true, + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + cachePolicy: { + version: 1, + baseUrl: 'https://cache.example/pbc/v1/cache', + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { + bridgeRecognizer: vi.fn(), + correctnessGptListeners: vi.fn(), + }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const renderCache = composition.runtimeSessionForTest()?.interfaces['renderDirectCache'] as + ((attempt: RenderAttempt, container: HTMLElement) => boolean) | undefined; + const fail = vi.fn(() => true); + expect(renderCache).toBeTypeOf('function'); + expect( + renderCache?.(Object.freeze({ fail }) as unknown as RenderAttempt, document.body) + ).toBe(false); + expect(fail).toHaveBeenCalledOnce(); + expect(fail).toHaveBeenCalledWith('cache_network_error'); + } finally { + composition.runtime.dispose(); + if (fetchDescriptor) Object.defineProperty(globalThis, 'fetch', fetchDescriptor); + else Reflect.deleteProperty(globalThis, 'fetch'); + } + }); + it('constructs or activates nothing after a terminal fallback', async () => { vi.useFakeTimers(); const serviceConstruction = vi.fn(() => ({ diff --git a/crates/trusted-server-js/lib/test/core/config.test.ts b/crates/trusted-server-js/lib/test/core/config.test.ts index 120bb43ac..42741b536 100644 --- a/crates/trusted-server-js/lib/test/core/config.test.ts +++ b/crates/trusted-server-js/lib/test/core/config.test.ts @@ -37,6 +37,41 @@ describe('config', () => { expect(Object.isFrozen(policy)).toBe(true); }); + it('accepts an exact 4,096-byte cache base URL and rejects the next byte', async () => { + const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); + const prefix = 'https://cache.example/'; + const exactBaseUrl = `${prefix}${'x'.repeat(4_096 - prefix.length)}`; + expect(new TextEncoder().encode(exactBaseUrl)).toHaveLength(4_096); + + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl: exactBaseUrl })).toEqual({ + version: 1, + baseUrl: exactBaseUrl, + }); + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl: `${exactBaseUrl}x` })).toBeUndefined(); + }); + + it.each([4_095, 4_096, 4_097])( + 'enforces the cache base URL byte boundary for multibyte UTF-8 at %s bytes', + async (targetBytes) => { + const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); + const prefix = 'https://cache.example/'; + const remainingBytes = targetBytes - new TextEncoder().encode(prefix).byteLength; + const baseUrl = `${prefix}${'é'.repeat(Math.floor(remainingBytes / 2))}${ + remainingBytes % 2 === 0 ? '' : 'x' + }`; + expect(new TextEncoder().encode(baseUrl)).toHaveLength(targetBytes); + + if (targetBytes <= 4_096) { + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl })).toEqual({ + version: 1, + baseUrl, + }); + } else { + expect(parseCacheFetchPolicyV1({ version: 1, baseUrl })).toBeUndefined(); + } + } + ); + it('rejects malformed cache policies before integration preparation', async () => { const { parseCacheFetchPolicyV1 } = await import('../../src/core/config'); const accessor = { version: 1 } as { version: number; baseUrl?: string }; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index ce529a5ad..89b70807c 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -19,6 +19,8 @@ import { createSlotOperation, renderDirectCacheAttempt, renderDirectAdmAttempt, + resolveCacheAdmAttempt, + type CacheAdmSource, type CommittedRenderArtifact, type DirectAdmIframeConstructor, type DirectAdmIframeHandle, @@ -149,20 +151,24 @@ function owner( }, isCurrent: () => current && !disposed, prepareWinnerContext: (context: WinnerContext) => { - if (!scope.isCurrent() || winnerContext !== undefined) return undefined; + if (!scope.isCurrent()) return undefined; + const previous = winnerContext; + if (previous !== undefined && previous !== context) return undefined; let committed = false; return Object.freeze({ commit: () => { if (committed) return winnerContext === context; - if (!scope.isCurrent() || winnerContext !== undefined) return false; + if (!scope.isCurrent() || winnerContext !== previous) return false; winnerContext = context; committed = true; return true; }, rollback: () => { - if (committed && winnerContext === context) winnerContext = undefined; + if (committed && previous === undefined && winnerContext === context) { + winnerContext = undefined; + } committed = false; - return winnerContext === undefined; + return winnerContext === previous; }, }); }, @@ -2024,29 +2030,107 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); - it('accepts a same-origin basic response because request mode enforces CORS', async () => { - document.body.innerHTML = '
'; + it.each(['basic', 'default', undefined] as const)( + 'rejects a cross-origin response with non-CORS type %s', + async (responseType) => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const container = document.getElementById('fictional-slot')!; + + const basicResponse = new Response(JSON.stringify({ adm: '
cached
' })); + Object.defineProperty(basicResponse, 'type', { configurable: true, value: responseType }); + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container, + fetcher: async () => basicResponse, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(container.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + } + ); + + it('accepts a basic response only when the cache and publisher origins match', async () => { const render = attempt(); expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); - const container = document.getElementById('fictional-slot')!; + const response = new Response(JSON.stringify({ adm: '
same origin
' })); + Object.defineProperty(response, 'type', { configurable: true, value: 'basic' }); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher: async () => response, + onResolved, + publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, + }) + ).toBe(true); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
same origin
' }); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it('rejects a CORS response when the cache and publisher origins match', async () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher: async () => corsResponse(JSON.stringify({ adm: '
wrong type
' })), + onResolved, + publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(onResolved).not.toHaveBeenCalled(); + }); + + it('terminally rejects a foreign direct-cache container before fetching or mutating DOM', () => { + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const foreignContainer = document.implementation.createHTMLDocument().createElement('div'); + foreignContainer.append(document.createTextNode('foreign placeholder')); + const fetcher = vi.fn(); - const basicResponse = new Response(JSON.stringify({ adm: '
cached
' })); - Object.defineProperty(basicResponse, 'type', { configurable: true, value: 'basic' }); expect( renderDirectCacheAttempt({ attempt: render, cachePolicy: CACHE_POLICY, - container, - fetcher: async () => basicResponse, + container: foreignContainer, + fetcher, prepareIframe: prepareAdmIframe, publisherOrigin: window.location.origin, }) - ).toBe(true); - - const frame = await insertedCacheFrame(container, render); - frame.dispatchEvent(new Event('load')); - expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); - document.body.innerHTML = ''; + ).toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + expect(foreignContainer.querySelector('iframe')).toBeNull(); + expect(foreignContainer.textContent).toBe('foreign placeholder'); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'winner_not_renderable', + }); }); it('clears the fetch deadline at the final byte before preparing the ADM frame', async () => { @@ -2215,7 +2299,7 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); - it('renders a delayed owner-controlled cache claim as one PUC artifact', async () => { + it('resolves a delayed owner-controlled cache claim without constructing publisher DOM', async () => { document.body.innerHTML = '
placeholder
'; const scope = owner(); const artifacts = createCommittedArtifactStore(); @@ -2232,14 +2316,14 @@ describe('direct cache attempt rendering', () => { }) ); const container = document.getElementById('fictional-slot')!; + const onResolved = vi.fn((_source: unknown) => true); expect( - renderDirectCacheAttempt({ + resolveCacheAdmAttempt({ attempt: render, cachePolicy: CACHE_POLICY, - container, fetcher: fetchCache, - prepareIframe: prepareAdmIframe, + onResolved, publisherOrigin: window.location.origin, }) ).toBe(true); @@ -2252,21 +2336,194 @@ describe('direct cache attempt rendering', () => { resolveFetch?.( corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 9000 })) ); - const frame = await insertedCacheFrame(container, render); - expect(frame.srcdoc).toContain('
1
'); - expect(frame.srcdoc).not.toContain('9000'); - - if (render.snapshot().outcome === undefined) frame.dispatchEvent(new Event('load')); - expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); - expect(artifacts.current('fictional-slot')).toMatchObject({ - attemptId: render.id, - kind: 'puc', + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + const resolved = onResolved.mock.calls[0]?.[0]; + expect(resolved).toEqual({ + adm: '
1
', + height: 250, + type: 'adm', + version: 1, + width: 300, }); - expect(container.querySelector('span')).toBeNull(); + expect(Object.isFrozen(resolved)).toBe(true); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_insertion', + }); + expect(artifacts.current('fictional-slot')).toBeUndefined(); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('span')).not.toBeNull(); + expect(render.cancel('caller_aborted')).toBe(true); artifacts.dispose(); document.body.innerHTML = ''; }); + it('replaces the PUC cache deadline with the one-second owner-insertion deadline', async () => { + vi.useFakeTimers(); + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, CACHE_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + let resolveFetch: ((response: Response) => void) | undefined; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + + try { + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher, + onResolved, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.advanceTimersByTimeAsync(4_999); + expect(render.snapshot().outcome).toBeUndefined(); + resolveFetch?.(corsResponse(JSON.stringify({ adm: '
cached
' }))); + for (let index = 0; index < 20 && onResolved.mock.calls.length === 0; index += 1) { + await Promise.resolve(); + } + expect(onResolved).toHaveBeenCalledOnce(); + expect(render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_insertion', + }); + + await vi.advanceTimersByTimeAsync(999); + expect(render.snapshot().outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'owner_insertion_timeout', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('resolves a promoted Prebid cache lease from its captured projection after replacement', async () => { + const scope = owner(); + const service = reservations(); + const render = attempt(scope, { reservations: service }); + const prebidBid = Object.freeze({ cpm: 3.25 }); + const navigation = Object.freeze({ + generation: scope.navigationGeneration, + isCurrent: scope.isCurrent, + onDispose: scope.onDispose, + }); + let currentProjection: Readonly<{ + renderSource: ReservationRenderSource; + winnerContext: WinnerContext; + }> = Object.freeze({ + renderSource: CACHE_SOURCE, + winnerContext: Object.freeze({ selectedCpm: 3.25 }), + }); + expect( + service.registerPrebidLease({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigation, + auctionId: 'initial-auction', + adUnitCode: scope.slot, + renderSource: currentProjection.renderSource, + winnerContext: currentProjection.winnerContext, + prebidBid, + }) + ).toMatchObject({ ok: true }); + + currentProjection = Object.freeze({ + renderSource: Object.freeze({ + ...CACHE_SOURCE, + cacheId: '00000000-0000-4000-8000-000000000001', + }), + winnerContext: Object.freeze({ selectedCpm: 99 }), + }); + expect(currentProjection.winnerContext.selectedCpm).toBe(99); + expect(render.beginGamClaim()).toBe(true); + expect( + service.promotePrebidSelection({ + reservationId: RESERVATION_ID, + auctionId: 'initial-auction', + adUnitCode: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + prebidBid, + }) + ).toMatchObject({ ok: true }); + const claim = service.claim({ + reservationId: RESERVATION_ID, + slot: scope.slot, + navigationGeneration: scope.navigationGeneration, + attempt: scope, + pucSource: Object.freeze({ owner: 'puc' }), + }); + expect(claim).toMatchObject({ recognized: true, claimed: true }); + expect(render.admitClaimedWinner(claim)).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + + const fetcher = vi.fn(async (_input: string, _init: RequestInit) => + corsResponse(JSON.stringify({ adm: '
${AUCTION_PRICE}
', price: 99 })) + ); + const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); + expect( + resolveCacheAdmAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + fetcher, + onResolved, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + + expect(fetcher.mock.calls[0]?.[0]).toBe(CACHE_SOURCE.fetchUrl); + expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
3.25
' }); + expect(onResolved.mock.calls[0]?.[0]).not.toMatchObject({ adm: '
99
' }); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'waiting_for_insertion' }); + expect(render.cancel('caller_aborted')).toBe(true); + }); + + it('keeps cache deadline completion private to the resolver capability', () => { + const render = attempt(); + expect('beginCacheFetch' in render).toBe(false); + expect('cacheFetchCompleted' in render).toBe(false); + }); + + it('classifies malformed UTF-8 as an invalid cache response', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const malformed = cacheResponse(new Uint8Array([0xc3, 0x28])); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => malformed.response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + it.each([ ['raw markup', '
raw
'], ['array', JSON.stringify([{ adm: '
wrapped
' }])], @@ -2304,6 +2561,100 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); + it('keeps captured cache authorities when mutable globals are poisoned after module load', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const response = corsResponse(JSON.stringify({ adm: 7 })); + const nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const descriptorDescriptor = Object.getOwnPropertyDescriptor( + Object, + 'getOwnPropertyDescriptor' + ); + const hasOwnDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, 'hasOwnProperty'); + const finiteDescriptor = Object.getOwnPropertyDescriptor(Number, 'isFinite'); + const integerDescriptor = Object.getOwnPropertyDescriptor(Number, 'isInteger'); + const urlDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'URL'); + const encoderDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'TextEncoder'); + const decoderDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'TextDecoder'); + const abortDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'AbortController'); + + try { + Object.defineProperty(Object, 'getOwnPropertyDescriptor', { + configurable: true, + value: (target: object, name: PropertyKey) => { + const descriptor = Reflect.apply(nativeGetOwnPropertyDescriptor, Object, [target, name]); + return name === 'adm' && descriptor && 'value' in descriptor && descriptor.value === 7 + ? { ...descriptor, value: '
forged
' } + : descriptor; + }, + writable: true, + }); + Object.defineProperty(Object.prototype, 'hasOwnProperty', { + configurable: true, + value: () => false, + writable: true, + }); + Object.defineProperty(Number, 'isFinite', { + configurable: true, + value: () => true, + writable: true, + }); + Object.defineProperty(Number, 'isInteger', { + configurable: true, + value: () => true, + writable: true, + }); + for (const name of ['URL', 'TextEncoder', 'TextDecoder', 'AbortController'] as const) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: class PoisonedAuthority { + constructor() { + throw new Error(`poisoned ${name}`); + } + }, + writable: true, + }); + } + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + for (let index = 0; index < 20 && render.snapshot().outcome === undefined; index += 1) { + await Promise.resolve(); + } + } finally { + if (descriptorDescriptor) { + Object.defineProperty(Object, 'getOwnPropertyDescriptor', descriptorDescriptor); + } + if (hasOwnDescriptor) { + Object.defineProperty(Object.prototype, 'hasOwnProperty', hasOwnDescriptor); + } + if (finiteDescriptor) Object.defineProperty(Number, 'isFinite', finiteDescriptor); + if (integerDescriptor) Object.defineProperty(Number, 'isInteger', integerDescriptor); + if (urlDescriptor) Object.defineProperty(globalThis, 'URL', urlDescriptor); + if (encoderDescriptor) Object.defineProperty(globalThis, 'TextEncoder', encoderDescriptor); + if (decoderDescriptor) Object.defineProperty(globalThis, 'TextDecoder', decoderDescriptor); + if (abortDescriptor) { + Object.defineProperty(globalThis, 'AbortController', abortDescriptor); + } + } + + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + it('enforces the 512 KiB streamed-body limit before JSON parsing', async () => { document.body.innerHTML = '
'; const render = attempt(); @@ -2330,6 +2681,60 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); + it('accepts an exact 512 KiB JSON body and bounded ADM', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + const prefix = '{"adm":"'; + const suffix = '"}'; + const exactBody = `${prefix}${'x'.repeat(512 * 1024 - prefix.length - suffix.length)}${suffix}`; + expect(new TextEncoder().encode(exactBody)).toHaveLength(512 * 1024); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => corsResponse(exactBody), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = await insertedCacheFrame(document.getElementById('fictional-slot')!, render); + frame.dispatchEvent(new Event('load')); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + document.body.innerHTML = ''; + }); + + it('rejects an ADM whose auction-price expansion exceeds 512 KiB', async () => { + document.body.innerHTML = '
'; + const render = attempt(); + expect( + render.admitDirectWinner(CACHE_SOURCE, Object.freeze({ selectedCpm: Number.MAX_VALUE })) + ).toBe(true); + const body = JSON.stringify({ adm: '${AUCTION_PRICE}'.repeat(25_000) }); + expect(new TextEncoder().encode(body).byteLength).toBeLessThanOrEqual(512 * 1024); + + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => corsResponse(body), + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_invalid_response', + }) + ); + expect(document.querySelector('iframe')).toBeNull(); + document.body.innerHTML = ''; + }); + it('cancels an oversized streamed body before publishing a failure', async () => { document.body.innerHTML = '
'; const render = attempt(); @@ -2378,6 +2783,41 @@ describe('direct cache attempt rendering', () => { fetchUrl: `https://other.example/cache?uuid=${CACHE_ID}`, }), }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://user:password@cache.example:8443/pbc/v1/cache?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${CACHE_SOURCE.fetchUrl}#fragment`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:9443/pbc/v1/cache?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:8443/pbc/v1/other?uuid=${CACHE_ID}`, + }), + }, + { + policy: CACHE_POLICY, + source: Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `https://cache.example:8443/pbc/v1/cache?id=${CACHE_ID}`, + }), + }, { policy: CACHE_POLICY, source: Object.freeze({ @@ -2422,6 +2862,135 @@ describe('direct cache attempt rendering', () => { document.body.innerHTML = ''; }); + it.each([-1, 0, 1] as const)( + 'enforces the 4,096-byte canonical fetch URL boundary at delta %s', + async (delta) => { + const query = `?uuid=${CACHE_ID}`; + const prefix = 'https://cache.example/'; + const targetFetchBytes = 4_096 + delta; + const pathLength = + targetFetchBytes - + new TextEncoder().encode(prefix).byteLength - + new TextEncoder().encode(query).byteLength; + const policy = Object.freeze({ + version: 1 as const, + baseUrl: `${prefix}${'x'.repeat(pathLength)}`, + }); + const source = Object.freeze({ + ...CACHE_SOURCE, + fetchUrl: `${policy.baseUrl}${query}`, + }); + expect(new TextEncoder().encode(source.fetchUrl)).toHaveLength(targetFetchBytes); + document.body.innerHTML = '
'; + const render = attempt(owner(indexedAttemptId(500 + delta), 'url-boundary-slot'), { + prepareRenderSource: (candidate) => (candidate === source ? source : undefined), + }); + expect(render.admitDirectWinner(source, WINNER_CONTEXT)).toBe(true); + const fetchCache = vi.fn(async () => { + throw new Error('boundary transport stop'); + }); + const started = renderDirectCacheAttempt({ + attempt: render, + cachePolicy: policy, + container: document.getElementById('url-boundary-slot')!, + fetcher: fetchCache, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }); + + if (delta <= 0) { + expect(started).toBe(true); + expect(fetchCache).toHaveBeenCalledOnce(); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + } else { + expect(started).toBe(false); + expect(fetchCache).not.toHaveBeenCalled(); + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'descriptor_invalid', + }); + } + document.body.innerHTML = ''; + } + ); + + it.each(['timeout', 'caller cancellation'] as const)( + 'cancels an active body reader after one chunk on %s and ignores its late chunk', + async (settlement) => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const render = attempt(); + expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); + let resolveRead: ((value: { done: boolean; value: Uint8Array }) => void) | undefined; + const cancel = vi.fn(async () => undefined); + let reads = 0; + const read = vi.fn(() => { + reads += 1; + if (reads === 1) { + return Promise.resolve({ + done: false, + value: new TextEncoder().encode('{"adm":"first chunk'), + }); + } + return new Promise<{ done: boolean; value: Uint8Array }>((resolve) => { + resolveRead = resolve; + }); + }); + const response = Object.freeze({ + body: Object.freeze({ + getReader: () => + Object.freeze({ + cancel, + read, + releaseLock: vi.fn(), + }), + }), + ok: true, + type: 'cors' as const, + }) as unknown as Response; + + try { + expect( + renderDirectCacheAttempt({ + attempt: render, + cachePolicy: CACHE_POLICY, + container: document.getElementById('fictional-slot')!, + fetcher: async () => response, + prepareIframe: prepareAdmIframe, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + for (let index = 0; index < 5 && read.mock.calls.length < 2; index += 1) { + await Promise.resolve(); + } + expect(read).toHaveBeenCalledTimes(2); + + if (settlement === 'timeout') await vi.advanceTimersByTimeAsync(5_000); + else expect(render.cancel('caller_aborted')).toBe(true); + + expect(cancel).toHaveBeenCalledOnce(); + expect(render.snapshot().outcome).toEqual( + settlement === 'timeout' + ? { outcome: 'failed', reason: 'cache_network_error' } + : { outcome: 'cancelled', reason: 'caller_aborted' } + ); + resolveRead?.({ done: false, value: new TextEncoder().encode('{"adm":"late"}') }); + await Promise.resolve(); + await Promise.resolve(); + expect(document.querySelector('iframe')).toBeNull(); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + document.body.innerHTML = ''; + } + } + ); + it('aborts the cache request after five seconds and makes late work inert', async () => { vi.useFakeTimers(); document.body.innerHTML = '
'; @@ -2447,7 +3016,10 @@ describe('direct cache attempt rendering', () => { publisherOrigin: window.location.origin, }) ).toBe(true); - await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(4_999); + expect(signal?.aborted).toBe(false); + expect(render.snapshot().outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); expect(signal?.aborted).toBe(true); expect(render.snapshot().outcome).toEqual({ outcome: 'failed', diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index b37dc0342..b7446c5ad 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -1908,7 +1908,7 @@ describe('atomic claims and disposal', () => { expect(attempt.winnerContext).toBeUndefined(); expect(service.snapshotInventoryForTest().entriesWithPucSource).toBe(0); }); - it('preserves one cache source and immutable context after projection replacement', () => { + it('preserves one cache source and immutable context after registration input mutation', () => { const { navigation } = runtimeNavigation(); const attempt = renderAttempt(navigation); const service = serviceAt(() => 0); From d8a534042f8c735b1a61b3d40c47ef6be3708402 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:04:10 -0700 Subject: [PATCH 300/494] Implement the Universal Creative bridge lifecycle --- .../lib/src/adapters/messaging.ts | 97 +- .../lib/src/composition/browser.ts | 75 +- .../lib/src/services/puc_bridge.ts | 2226 ++++++++++++++++- .../lib/test/adapters/messaging.test.ts | 26 + .../lib/test/composition/browser.test.ts | 28 +- .../lib/test/services/puc_bridge.test.ts | 2085 ++++++++++++++- ...8-04-aps-tsjs-resilience-implementation.md | 24 +- ...s-render-fix-and-tsjs-resilience-design.md | 13 +- 8 files changed, 4484 insertions(+), 90 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index ff50f86cd..eb8f9c425 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -234,6 +234,13 @@ export interface MessagingAdapter { event: unknown, expectedCount: 0 | 1 | 2 ): readonly MessagingPort[] | undefined; + inspectTransferredPorts(event: unknown): + | Readonly<{ + exactShape: boolean; + originalCount: number; + ports: readonly MessagingPort[]; + }> + | undefined; } /** Semantic validators injected by composition without reversing adapter layering. */ @@ -1053,9 +1060,14 @@ function wrapPort(raw: RawPort, transferable = false): MessagingPort { return port; } -function snapshotPortArray( - candidate: unknown -): { readonly valid: boolean; readonly values: readonly unknown[] } | undefined { +function snapshotPortArray(candidate: unknown): + | { + readonly exactShape: boolean; + readonly originalCount: number; + readonly valid: boolean; + readonly values: readonly unknown[]; + } + | undefined { try { if (!Array.isArray(candidate) || Object.getPrototypeOf(candidate) !== Array.prototype) { return undefined; @@ -1073,7 +1085,8 @@ function snapshotPortArray( const length = lengthDescriptor.value; const ownKeys = Reflect.ownKeys(candidate); const values: unknown[] = []; - let valid = length <= 2 && ownKeys.length === length + 1; + let exactShape = ownKeys.length === length + 1; + let valid = length <= 2 && exactShape; if (length <= 2) { for (let keyIndex = 0; keyIndex < ownKeys.length; keyIndex += 1) { const key = ownKeys[keyIndex]; @@ -1085,11 +1098,15 @@ function snapshotPortArray( break; } } - if (!expected) valid = false; + if (!expected) { + exactShape = false; + valid = false; + } } for (let index = 0; index < length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + exactShape = false; valid = false; continue; } @@ -1098,18 +1115,25 @@ function snapshotPortArray( } else { for (let keyIndex = 0; keyIndex < ownKeys.length; keyIndex += 1) { const key = ownKeys[keyIndex]; - if (typeof key !== 'string' || key === 'length') continue; + if (key === 'length') continue; + if (typeof key !== 'string') { + exactShape = false; + continue; + } const index = Number(key); if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + exactShape = false; continue; } const descriptor = Object.getOwnPropertyDescriptor(candidate, key); if (descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value')) { values[values.length] = descriptor.value; + } else { + exactShape = false; } } } - return { valid, values }; + return { exactShape, originalCount: length, valid, values }; } catch { return undefined; } @@ -1155,9 +1179,10 @@ function commitTransferReservation(reservation: TransferReservation): void { } } -function extractTransferredPorts( +function extractTransferredPortsInRange( event: unknown, - expectedCount: 0 | 1 | 2 + minimumCount: 0 | 1 | 2, + maximumCount: 0 | 1 | 2 ): readonly MessagingPort[] | undefined { let candidates: unknown; try { @@ -1170,7 +1195,10 @@ function extractTransferredPorts( if (!snapshot) return undefined; const inspections: Array = []; const claimed: boolean[] = []; - let accepted = snapshot.valid && snapshot.values.length === expectedCount; + let accepted = + snapshot.valid && + snapshot.values.length >= minimumCount && + snapshot.values.length <= maximumCount; for (let index = 0; index < snapshot.values.length; index += 1) { const candidate = snapshot.values[index]; const candidateClaimed = claimPortCandidate(candidate); @@ -1209,6 +1237,53 @@ function extractTransferredPorts( } } +function extractTransferredPorts( + event: unknown, + expectedCount: 0 | 1 | 2 +): readonly MessagingPort[] | undefined { + return extractTransferredPortsInRange(event, expectedCount, expectedCount); +} + +function inspectTransferredPorts(event: unknown): + | Readonly<{ + exactShape: boolean; + originalCount: number; + ports: readonly MessagingPort[]; + }> + | undefined { + let candidates: unknown; + try { + if (typeof event !== 'object' || event === null) return undefined; + candidates = Reflect.get(event, 'ports'); + } catch { + return undefined; + } + const snapshot = snapshotPortArray(candidates); + if (!snapshot) return undefined; + const wrapped: MessagingPort[] = []; + try { + for (let index = 0; index < snapshot.values.length; index += 1) { + const candidate = snapshot.values[index]; + if (!claimPortCandidate(candidate)) continue; + const inspection = inspectRawPort(candidate); + if (!inspection.raw) { + if (inspection.close) closeCapturedRawPort(inspection.close); + else closeRawPort(candidate); + continue; + } + wrapped[wrapped.length] = wrapPort(inspection.raw); + } + return Object.freeze({ + exactShape: snapshot.exactShape, + originalCount: snapshot.originalCount, + ports: Object.freeze(wrapped), + }); + } catch { + for (let index = 0; index < wrapped.length; index += 1) wrapped[index]?.close(); + return undefined; + } +} + function createChannel(target: MessageEventTarget): MessagingChannel | undefined { let first: unknown; let second: unknown; @@ -1348,6 +1423,7 @@ export function createBrowserMessagingAdapter( parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, validation), extractTransferredPorts, + inspectTransferredPorts, }); } @@ -1361,5 +1437,6 @@ export function createNoopMessagingAdapter(): MessagingAdapter { parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), extractTransferredPorts, + inspectTransferredPorts, }); } diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index eef7e1967..6f97ff093 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -24,7 +24,7 @@ import { } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; import { prepareAdmIframe } from '../core/render'; -import { renderDirectApsAttempt } from '../integrations/aps/render'; +import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -39,11 +39,13 @@ import { import { createReservationService, type ReservationService } from '../services/reservations'; import { createRendererNonceRegistry, + resolveCacheAdmAttempt, renderDirectCacheAttempt, renderDirectAdmAttempt, type RenderAttempt, type RendererNonceRegistry, } from '../services/render'; +import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; import { createSlotService, type SlotService } from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; @@ -58,6 +60,7 @@ export interface BrowserComposition { } export interface BrowserServices { + readonly pucBridge: PucBridge; readonly reservations: ReservationService; readonly rendererNonces: RendererNonceRegistry; readonly renderDirectAdm: (attempt: RenderAttempt, container: HTMLElement) => boolean; @@ -93,13 +96,11 @@ export interface BrowserRuntimeComposition extends BrowserComposition { readonly reservationServiceForTest: () => ReservationService | undefined; /** Return runtime-owned renderer nonces only in coordinated-cutover tests. */ readonly rendererNonceRegistryForTest: () => RendererNonceRegistry | undefined; + /** Return the single runtime-owned PUC bridge only in coordinated-cutover tests. */ + readonly pucBridgeForTest: () => PucBridge | undefined; } export interface BrowserCoreActivations { - readonly bridgeRecognizer: ( - context: CoreActivationContext, - adapters: Readonly - ) => void; readonly correctnessGptListeners: ( context: CoreActivationContext, adapters: Readonly, @@ -121,6 +122,13 @@ interface AcceptedBrowserBoot { }; } +interface PreparedBrowserServices { + readonly publisherOrigin: string; + readonly rendererUrl: string; + readonly resolveCacheAdm: NonNullable; + readonly services: Readonly>; +} + function projectionSlots(projection: object): readonly string[] { const accepted = projection as { readonly auction: { readonly results: readonly { readonly slot: string }[] }; @@ -197,6 +205,7 @@ export function createTestBrowserRuntimeComposition( ): BrowserRuntimeComposition { const composition = createBrowserComposition(compositionOptions); let runtimeSession: RuntimeSession | undefined; + let preparedBrowserServices: PreparedBrowserServices | undefined; let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; @@ -221,6 +230,7 @@ export function createTestBrowserRuntimeComposition( const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; const fetchCache = globalThis.fetch; + const rendererUrl = new URL(APS_RENDERER_V1_PATH, publisherOrigin).href; const renderDirectAdm = (attempt: RenderAttempt, container: HTMLElement): boolean => { try { return renderDirectAdmAttempt({ @@ -276,6 +286,38 @@ export function createTestBrowserRuntimeComposition( return false; } }; + const resolveCacheAdm: NonNullable = ( + attempt, + onResolved + ): boolean => { + if (!cachePolicy) { + try { + attempt.fail('descriptor_invalid'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + if (typeof fetchCache !== 'function') { + try { + attempt.fail('cache_network_error'); + } catch { + // The admitted attempt remains the only terminal authority. + } + return false; + } + try { + return resolveCacheAdmAttempt({ + attempt: attempt as RenderAttempt, + cachePolicy, + fetcher: (input, init) => fetchCache(input, init), + onResolved, + publisherOrigin, + }); + } catch { + return false; + } + }; const services = Object.freeze({ reservations: reservationService, rendererNonces, @@ -285,6 +327,12 @@ export function createTestBrowserRuntimeComposition( slots: slotService, targeting: targetingService, }); + preparedBrowserServices = Object.freeze({ + publisherOrigin, + rendererUrl, + resolveCacheAdm, + services, + }); const session = createRuntimeSession({ createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, @@ -300,6 +348,7 @@ export function createTestBrowserRuntimeComposition( composition.adapters.prebid.dispose(); if (runtimeSession === session) { runtimeSession = undefined; + preparedBrowserServices = undefined; browserServices = undefined; auctionContextRegistry = undefined; projectionParser = undefined; @@ -326,14 +375,23 @@ export function createTestBrowserRuntimeComposition( runtimeOwner: session, }); runtimeSession = session; - browserServices = services; auctionContextRegistry = contextRegistry; projectionParser = parseProjection; return runtimeOptions.activateOwner?.(context); }, activateCore: (context) => { - if (!browserServices) throw new Error('Browser services are unavailable'); - compositionOptions.coreActivations.bridgeRecognizer(context, composition.adapters); + const prepared = preparedBrowserServices; + if (!prepared) throw new Error('Browser services are unavailable'); + const pucBridge = createPucBridge({ + messaging: composition.adapters.messaging, + publisherOrigin: prepared.publisherOrigin, + rendererNonces: prepared.services.rendererNonces, + rendererUrl: prepared.rendererUrl, + reservations: prepared.services.reservations, + resolveCacheAdm: prepared.resolveCacheAdm, + }); + context.onDispose(() => pucBridge.dispose()); + browserServices = Object.freeze({ ...prepared.services, pucBridge }); browserServices.slots.activate(); compositionOptions.coreActivations.correctnessGptListeners( context, @@ -362,5 +420,6 @@ export function createTestBrowserRuntimeComposition( targetingServiceForTest: () => browserServices?.targeting, reservationServiceForTest: () => browserServices?.reservations, rendererNonceRegistryForTest: () => browserServices?.rendererNonces, + pucBridgeForTest: () => browserServices?.pucBridge, }); } diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index be4772c05..91e0d6d67 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -3,42 +3,768 @@ import { type MessagingAdapter, type MessagingPort, } from '../adapters/messaging'; +import { mintBrowserLifecycleTicket } from '../kernel/identity'; +import type { IdentityGenerationResult } from '../kernel/identity'; -import type { ReservationRecognition, ReservationService } from './reservations'; +import type { + CacheAdmSource, + CommittedRenderArtifact, + RenderAttempt, + RenderFailureReason, + RenderOutcome, + RendererNonceRegistry, +} from './render'; +import type { + ReservationAttempt, + ReservationRecognition, + ReservationRenderSource, + ReservationService, +} from './reservations'; +const CLAIM_DEADLINE_MS = 3_000; +const LIFECYCLE_TICKET_TTL_MS = 3_000; +const MAX_DYNAMIC_OWNER_BYTES = 64 * 1_024; +const MAX_OUTER_RESPONSE_BYTES = 72 * 1_024; +const MAX_TICKET_DRAWS = 8; +const MAX_TICKETS = 320; +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const ATTEMPT_ID = /^a1_[A-Za-z0-9_-]{22}$/; +const LIFECYCLE_TICKET = /^t1_[A-Za-z0-9_-]{22}$/; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const mapGetIntrinsic = Map.prototype.get; const mapSetIntrinsic = Map.prototype.set; +const mapDeleteIntrinsic = Map.prototype.delete; const mapClearIntrinsic = Map.prototype.clear; +const mapEntriesIntrinsic = Map.prototype.entries; const mapSizeGetter = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get as ( this: Map ) => number; const mapValuesIntrinsic = Map.prototype.values; +const mapEntryIteratorNextIntrinsic = Object.getPrototypeOf(new Map().entries()).next as ( + this: IterableIterator +) => IteratorResult; const mapIteratorNextIntrinsic = Object.getPrototypeOf(new Map().values()).next as ( this: IterableIterator ) => IteratorResult; const jsonStringifyIntrinsic = JSON.stringify; const objectFreezeIntrinsic = Object.freeze; +/** + * Install the self-contained renderer that PUC evaluates in its hidden frame. + * + * This function deliberately closes over nothing: its serialized source is the + * exact program returned in the successful outer PUC response. + */ +function installPucDynamicOwner(): void { + const ownerWindow = window as Window & { + render?: (data: unknown, helper: unknown, creativeWindow: Window) => Promise; + }; + const ticketPattern = /^t1_[A-Za-z0-9_-]{22}$/; + const reservationPattern = /^r1_[A-Za-z0-9_-]{22}$/; + const admSandbox = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; + const apsSandbox = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; + const renderFailureReasons = new Set([ + 'auction_timeout', + 'auction_disabled', + 'consent_denied', + 'slot_not_eligible', + 'provider_timeout', + 'provider_error', + 'invalid_provider_response', + 'mediation_failed', + 'winner_not_renderable', + 'internal_error', + 'network_error', + 'http_error', + 'invalid_response', + 'slot_unresolved', + 'descriptor_invalid', + 'invalid_dimensions', + 'dimensions_out_of_range', + 'no_render_source', + 'registry_full', + 'capability_registry_full', + 'external_queue_full', + 'external_ready_timeout', + 'external_artifact_incompatible', + 'prebid_admission_failed', + 'prebid_contract_violation', + 'prebid_selection_timeout', + 'reservation_collision', + 'identity_generation_failed', + 'cycle_unattributable', + 'slot_quarantined', + 'gpt_request_failed', + 'gpt_request_timeout', + 'gpt_completion_timeout', + 'reconciliation_capacity', + 'gam_empty', + 'bridge_claim_timeout', + 'bridge_id_mismatch', + 'owner_registration_timeout', + 'owner_insertion_timeout', + 'renderer_document_no_load', + 'runner_no_load', + 'runner_failed', + 'cache_network_error', + 'cache_http_error', + 'cache_invalid_response', + 'adm_document_no_load', + 'abi_mismatch', + 'bundle_partial', + ]); + const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); + + const ownDataValue = (candidate: unknown, name: string): unknown => { + try { + if (typeof candidate !== 'object' || candidate === null) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } + }; + + const exactRecord = ( + candidate: unknown, + keys: readonly string[] + ): Record | undefined => { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(candidate).sort(); + const expected = [...keys].sort(); + if (names.length !== expected.length) return undefined; + for (let index = 0; index < expected.length; index += 1) { + if (names[index] !== expected[index]) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, expected[index] as string); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + } + return candidate as Record; + }; + const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { + try { + if (typeof event !== 'object' || event === null) return undefined; + const ports = Reflect.get(event, 'ports') as unknown; + if (!Array.isArray(ports) || ports.length !== count) return undefined; + for (let index = 0; index < ports.length; index += 1) { + const port = ports[index] as Partial | undefined; + if (!port || typeof port.postMessage !== 'function' || typeof port.close !== 'function') { + return undefined; + } + } + return ports as MessagePort[]; + } catch { + return undefined; + } + }; + const closeEventPorts = (event: unknown): void => { + try { + if (typeof event !== 'object' || event === null) return; + const ports = Reflect.get(event, 'ports') as unknown; + if (!Array.isArray(ports)) return; + for (let index = 0; index < ports.length; index += 1) { + try { + const port = ports[index] as Partial | undefined; + if (typeof port?.close === 'function') port.close(); + } catch { + // Late or malformed endpoints are still contained independently. + } + } + } catch { + // A hostile event cannot interrupt terminal cleanup. + } + }; + const parseRegistration = (value: unknown): Record | undefined => { + try { + if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 4096) { + return undefined; + } + return exactRecord(JSON.parse(value) as unknown, [ + 'message', + 'adId', + 'version', + 'lifecycleTicket', + ]); + } catch { + return undefined; + } + }; + const validDimension = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 4096; + const validAdmSource = (value: unknown): value is Record => { + const source = exactRecord(value, ['type', 'version', 'adm', 'width', 'height']); + if ( + !source || + source['type'] !== 'adm' || + source['version'] !== 1 || + typeof source['adm'] !== 'string' || + source['adm'].trim().length === 0 || + new TextEncoder().encode(source['adm']).byteLength > 512 * 1024 || + !validDimension(source['width']) || + !validDimension(source['height']) + ) { + return false; + } + return true; + }; + + ownerWindow.render = (data, helper, creativeWindow) => + new Promise((resolve, reject) => { + let outer: Record | undefined; + let owner: Record | undefined; + let sendMessage: unknown; + try { + outer = exactRecord(data, ['adId', 'message', 'renderer', 'rendererVersion', 'tsOwner']); + owner = outer + ? exactRecord(outer['tsOwner'], ['version', 'status', 'kind', 'lifecycleTicket']) + : undefined; + sendMessage = + typeof helper === 'object' && helper !== null + ? Reflect.get(helper, 'sendMessage') + : undefined; + } catch { + outer = undefined; + } + const adId = outer?.['adId']; + const lifecycleTicket = owner?.['lifecycleTicket']; + if ( + !outer || + !owner || + outer['message'] !== 'Prebid Response' || + outer['rendererVersion'] !== '3' || + typeof adId !== 'string' || + !reservationPattern.test(adId) || + owner['version'] !== 1 || + owner['status'] !== 'ready' || + (owner['kind'] !== 'aps' && owner['kind'] !== 'adm') || + typeof lifecycleTicket !== 'string' || + !ticketPattern.test(lifecycleTicket) || + typeof sendMessage !== 'function' || + !creativeWindow || + !creativeWindow.document + ) { + reject(new Error('TS render owner input refused')); + return; + } + + let settled = false; + let registrationFinished = false; + let helperDisposer: (() => void) | undefined; + let ownerTimer: number | undefined; + let controlPort: MessagePort | undefined; + let documentPort: MessagePort | undefined; + let frame: HTMLIFrameElement | undefined; + let frameCommitted = false; + let localApsFailure = false; + let started = false; + + const removeFrameHandlers = (): void => { + if (!frame) return; + frame.onload = null; + frame.onerror = null; + }; + const closePort = (port: MessagePort | undefined): void => { + try { + port?.close(); + } catch { + // Endpoint cleanup remains best-effort after the owner is inert. + } + }; + const stopHelper = (): void => { + const dispose = helperDisposer; + helperDisposer = undefined; + try { + dispose?.(); + } catch { + // PUC helper cleanup cannot replay owner settlement. + } + }; + const finish = (accepted: boolean, reason: string): void => { + if (settled) return; + settled = true; + if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); + if (ownerTimer !== undefined) creativeWindow.clearTimeout(ownerTimer); + stopHelper(); + removeFrameHandlers(); + if (!accepted && frame && !frameCommitted) frame.remove(); + if (controlPort) { + controlPort.onmessage = null; + controlPort.onmessageerror = null; + } + closePort(documentPort); + closePort(controlPort); + documentPort = undefined; + controlPort = undefined; + if (accepted) resolve(); + else reject(new Error(reason)); + }; + const postControl = (message: Record): boolean => { + try { + if (!controlPort || settled) return false; + controlPort.postMessage(message); + return true; + } catch { + finish(false, 'TS render owner control post failed'); + return false; + } + }; + const configureFrame = ( + source: Record, + sandbox: string + ): HTMLIFrameElement => { + const width = source['width'] as number; + const height = source['height'] as number; + const next = creativeWindow.document.createElement('iframe'); + next.setAttribute('sandbox', sandbox); + next.setAttribute('referrerpolicy', 'no-referrer'); + next.setAttribute('width', String(width)); + next.setAttribute('height', String(height)); + next.setAttribute('scrolling', 'no'); + next.setAttribute('frameborder', '0'); + next.setAttribute('marginwidth', '0'); + next.setAttribute('marginheight', '0'); + next.setAttribute('title', 'Ad content'); + next.setAttribute('aria-label', 'Advertisement'); + next.setAttribute( + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); + return next; + }; + const prepareDocument = (): void => { + const document = creativeWindow.document; + document.documentElement.style.margin = '0'; + document.documentElement.style.padding = '0'; + document.documentElement.style.overflow = 'hidden'; + if (document.body) { + document.body.style.margin = '0'; + document.body.style.padding = '0'; + document.body.style.overflow = 'hidden'; + } + }; + const insertAdm = (source: Record): void => { + if (!validAdmSource(source) || !creativeWindow.document.body) { + finish(false, 'TS ADM source refused'); + return; + } + prepareDocument(); + const next = configureFrame(source, admSandbox); + next.onload = () => { + if (!settled && frame === next && next.isConnected) { + postControl({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket, + }); + } + }; + next.onerror = () => { + if (!settled && frame === next) { + postControl({ + message: 'TS ADM Failed', + version: 1, + lifecycleTicket, + }); + } + }; + next.srcdoc = `${source['adm'] as string}`; + frame = next; + creativeWindow.document.body.appendChild(next); + postControl({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket, + }); + }; + const insertAps = (start: Record, ports: MessagePort[]): void => { + const envelope = exactRecord(start['envelope'], [ + 'version', + 'nonce', + 'publisherOrigin', + 'renderer', + ]); + const rendererCandidate = envelope?.['renderer']; + const renderer = envelope + ? exactRecord(rendererCandidate, [ + 'type', + 'version', + 'accountId', + 'bidId', + 'tagType', + 'creativeUrl', + 'width', + 'height', + 'aaxResponse', + ...(typeof rendererCandidate === 'object' && + rendererCandidate !== null && + Object.prototype.hasOwnProperty.call(rendererCandidate, 'creativeId') + ? ['creativeId'] + : []), + ]) + : undefined; + const rendererUrl = start['rendererUrl']; + let parsedUrl: URL | undefined; + let parsedPublisherOrigin: URL | undefined; + try { + parsedUrl = typeof rendererUrl === 'string' ? new URL(rendererUrl) : undefined; + parsedPublisherOrigin = + typeof envelope?.['publisherOrigin'] === 'string' + ? new URL(envelope['publisherOrigin']) + : undefined; + } catch { + parsedUrl = undefined; + parsedPublisherOrigin = undefined; + } + if ( + !envelope || + !renderer || + envelope['version'] !== 1 || + typeof envelope['nonce'] !== 'string' || + !/^n1_[A-Za-z0-9_-]{22}$/.test(envelope['nonce']) || + typeof envelope['publisherOrigin'] !== 'string' || + new TextEncoder().encode(envelope['publisherOrigin']).byteLength > 2048 || + renderer['type'] !== 'aps' || + renderer['version'] !== 1 || + !validDimension(renderer['width']) || + !validDimension(renderer['height']) || + !parsedUrl || + !parsedPublisherOrigin || + new TextEncoder().encode(String(rendererUrl)).byteLength > 2048 || + (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') || + parsedUrl.hostname === '' || + parsedUrl.username !== '' || + parsedUrl.password !== '' || + parsedUrl.pathname !== '/integrations/aps/renderer/v1' || + parsedUrl.search !== '' || + parsedUrl.hash !== '' || + (parsedPublisherOrigin.protocol !== 'https:' && + parsedPublisherOrigin.protocol !== 'http:') || + parsedPublisherOrigin.hostname === '' || + parsedPublisherOrigin.username !== '' || + parsedPublisherOrigin.password !== '' || + parsedPublisherOrigin.origin !== envelope['publisherOrigin'] || + parsedPublisherOrigin.pathname !== '/' || + parsedPublisherOrigin.search !== '' || + parsedPublisherOrigin.hash !== '' || + parsedUrl.origin !== parsedPublisherOrigin.origin || + ports.length !== 1 || + !creativeWindow.document.body + ) { + closePort(ports[0]); + finish(false, 'TS APS start refused'); + return; + } + prepareDocument(); + documentPort = ports[0]; + const next = configureFrame(renderer, apsSandbox); + const containLocalFailure = (transferred?: MessagePort): void => { + localApsFailure = true; + next.onload = null; + next.onerror = null; + closePort(transferred); + if (documentPort) { + closePort(documentPort); + documentPort = undefined; + } + next.remove(); + }; + next.onload = () => { + if (settled || frame !== next || !next.isConnected || !documentPort) return; + const transferred = documentPort; + documentPort = undefined; + try { + const target = next.contentWindow; + if (!target) throw new Error('APS document target is unavailable'); + target.postMessage(envelope, '*', [transferred]); + } catch { + containLocalFailure(transferred); + } + }; + next.onerror = () => containLocalFailure(); + next.src = `${parsedUrl.href}#tsaps=${envelope['nonce'] as string}`; + frame = next; + creativeWindow.document.body.appendChild(next); + postControl({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket, + }); + }; + const receiveControl = (event: MessageEvent): void => { + if (settled) { + closeEventPorts(event); + return; + } + const ports = eventPorts(event, 0) ?? eventPorts(event, 1); + const dataValue = ownDataValue(event, 'data'); + const routedMessage = ownDataValue(dataValue, 'message'); + const routedOutcome = ownDataValue(dataValue, 'outcome'); + const message = exactRecord(dataValue, [ + 'message', + 'version', + 'lifecycleTicket', + ...(routedMessage === 'TS APS Start' + ? ['rendererUrl', 'envelope'] + : routedMessage === 'TS ADM Start' + ? ['source'] + : routedMessage === 'TS Owner Settled' && routedOutcome !== undefined + ? routedOutcome === 'accepted' + ? ['outcome'] + : ['outcome', 'reason'] + : []), + ]); + if ( + !message || + !ports || + message['version'] !== 1 || + message['lifecycleTicket'] !== lifecycleTicket + ) { + closeEventPorts(event); + finish(false, 'TS render owner control refused'); + return; + } + if (message['message'] === 'TS ADM Start' && ports.length === 0 && !started) { + started = true; + insertAdm(message['source'] as Record); + return; + } + if (message['message'] === 'TS APS Start' && ports.length === 1 && !started) { + started = true; + insertAps(message, ports); + return; + } + if (message['message'] === 'TS Owner Settled' && ports.length === 0) { + if (message['outcome'] === 'accepted' && !localApsFailure && frame && frame.isConnected) { + frameCommitted = true; + finish(true, ''); + return; + } + if ( + message['outcome'] === 'failed' && + typeof message['reason'] === 'string' && + renderFailureReasons.has(message['reason']) + ) { + finish(false, message['reason']); + return; + } + if ( + message['outcome'] === 'cancelled' && + typeof message['reason'] === 'string' && + cancellationReasons.has(message['reason']) + ) { + finish(false, String(message['reason'])); + return; + } + } + closeEventPorts(event); + finish(false, 'TS render owner control refused'); + }; + const receiveRegistration = (event: unknown): void => { + if (settled || registrationFinished) { + closeEventPorts(event); + return; + } + registrationFinished = true; + stopHelper(); + if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); + const ports = eventPorts(event, 1); + let dataValue: unknown; + try { + dataValue = + typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; + } catch { + dataValue = undefined; + } + const response = parseRegistration(dataValue); + if ( + !ports || + !response || + response['message'] !== 'TS Render Owner Registered' || + response['adId'] !== adId || + response['version'] !== 1 || + response['lifecycleTicket'] !== lifecycleTicket + ) { + closeEventPorts(event); + finish(false, 'TS render owner registration refused'); + return; + } + const registeredPort = ports[0]; + if (!registeredPort) { + finish(false, 'TS render owner registration refused'); + return; + } + controlPort = registeredPort; + ownerTimer = creativeWindow.setTimeout( + () => finish(false, 'TS render owner settlement timeout'), + 20_000 + ); + registeredPort.onmessage = receiveControl; + registeredPort.onmessageerror = () => finish(false, 'TS render owner channel failed'); + try { + registeredPort.start(); + } catch { + finish(false, 'TS render owner channel failed'); + } + }; + + const registrationTimer = creativeWindow.setTimeout( + () => finish(false, 'TS render owner registration timeout'), + 3_000 + ); + try { + const disposer = Reflect.apply(sendMessage, helper, [ + 'TS Render Owner Register', + { version: 1, lifecycleTicket }, + receiveRegistration, + ]) as unknown; + if (typeof disposer !== 'function') { + finish(false, 'TS render owner registration failed'); + return; + } + helperDisposer = disposer as () => void; + if (registrationFinished) stopHelper(); + } catch { + finish(false, 'TS render owner registration failed'); + } + }); +} + +/** Exact checked-in program returned through PUC's dynamic renderer field. */ +export const PUC_DYNAMIC_OWNER = `(${String(installPucDynamicOwner)})();`; + interface PendingClaim { readonly port: MessagingPort; readonly source: object; } +export interface PucRenderAttempt { + readonly id: string; + readonly slot: string; + readonly generation: object; + readonly navigationGeneration: object; + readonly renderSource: ReservationRenderSource | undefined; + readonly beginGamClaim: () => boolean; + readonly admitClaimedWinner: (claim: unknown) => boolean; + readonly ownerClaimed: () => boolean; + readonly ownerRegistered: () => boolean; + readonly beginApsDocument: (artifact: CommittedRenderArtifact) => boolean; + readonly beginAdm: (artifact: CommittedRenderArtifact) => boolean; + readonly apsDocumentAccepted: () => boolean; + readonly accept: () => boolean; + readonly cancel: (reason: 'caller_aborted' | 'superseded' | 'navigation_disposed') => boolean; + readonly fail: (reason: RenderFailureReason) => boolean; + readonly onSettled: (callback: (outcome: RenderOutcome) => void) => boolean; + readonly snapshot: () => Readonly<{ + state: string; + outcome: Readonly | undefined; + }>; +} + +export interface PucGamAttemptInput { + readonly attempt: PucRenderAttempt; + readonly artifact: CommittedRenderArtifact; + readonly owner: ReservationAttempt & + Readonly<{ generation: object; navigationGeneration: object }>; + readonly reservationId: string; +} + +export interface PucBridgeScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + export interface PucBridgeOptions { readonly messaging: MessagingAdapter; - readonly reservations: Pick; + readonly reservations: Pick & + Partial>; + readonly mintLifecycleTicket?: () => IdentityGenerationResult; + readonly now?: () => number; + readonly publisherOrigin?: string; + readonly rendererNonces?: Pick; + readonly rendererUrl?: string; + readonly resolveCacheAdm?: ( + attempt: PucRenderAttempt, + onResolved: (source: Readonly) => boolean + ) => boolean; + readonly scheduler?: PucBridgeScheduler; } export interface PucBridgeInventory { + readonly attempts: number; readonly disposed: boolean; + readonly liveTickets: number; readonly pendingClaims: number; + readonly ticketTombstones: number; } export interface PucBridge { + registerGamAttempt(input: PucGamAttemptInput): boolean; + recordNonemptyGam(input: PucGamAttemptInput): boolean; dispose(): void; snapshotInventoryForTest(): PucBridgeInventory; } +interface GamAttemptBinding { + readonly reservationId: string; + readonly attempt: PucRenderAttempt; + readonly artifact: CommittedRenderArtifact; + readonly owner: PucGamAttemptInput['owner']; + readonly attemptId: string; + readonly slot: string; + readonly navigationGeneration: object; + artifactOwned: boolean; + active: boolean; + claim: PendingClaim | undefined; + claimDeadlineHandle: unknown; + controlListenerDispose: (() => void) | undefined; + controlPort: MessagingPort | undefined; + controlStarted: boolean; + documentAccepted: boolean; + documentAcceptancePending: boolean; + documentTerminalPending: 'completed' | RenderFailureReason | undefined; + documentListenerDispose: (() => void) | undefined; + documentPort: MessagingPort | undefined; + documentPortRegistryOwned: boolean; + documentTransferredPort: MessagingPort | undefined; + gamReady: boolean; + joining: boolean; + lifecycleTicket: string | undefined; + nonce: string | undefined; + ownerInserted: boolean; + pucSource: object | undefined; + ticket: string | undefined; +} + +interface LiveTicket { + readonly state: 'live'; + readonly binding: GamAttemptBinding; + readonly expiresAt: number; + expiryHandle: unknown; +} + +interface PendingTicket { + readonly state: 'pending'; + readonly binding: GamAttemptBinding; +} + +interface TicketTombstone { + readonly state: 'tombstone'; + readonly expiresAt: number; + expiryHandle: unknown; +} + +type TicketEntry = LiveTicket | PendingTicket | TicketTombstone; + function mapValue(map: Map, key: Key): Value | undefined { return Reflect.apply(mapGetIntrinsic, map, [key]) as Value | undefined; } @@ -47,10 +773,26 @@ function setMapValue(map: Map, key: Key, value: Value): Reflect.apply(mapSetIntrinsic, map, [key, value]); } +function deleteMapValue(map: Map, key: Key): boolean { + return Reflect.apply(mapDeleteIntrinsic, map, [key]) as boolean; +} + function mapSize(map: Map): number { return Reflect.apply(mapSizeGetter, map, []) as number; } +function snapshotMapEntries(map: Map): readonly [Key, Value][] { + const iterator = Reflect.apply(mapEntriesIntrinsic, map, []) as IterableIterator<[Key, Value]>; + const entries: Array<[Key, Value]> = []; + while (true) { + const step = Reflect.apply(mapEntryIteratorNextIntrinsic, iterator, []) as IteratorResult< + [Key, Value] + >; + if (step.done) return entries; + entries[entries.length] = step.value; + } +} + function snapshotMapValues(map: Map): readonly Value[] { const iterator = Reflect.apply(mapValuesIntrinsic, map, []) as IterableIterator; const values: Value[] = []; @@ -65,6 +807,98 @@ function frozen(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } +function utf8Length(value: string): number { + return (Reflect.apply(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array).byteLength; +} + +function defaultNow(): number { + return Date.now(); +} + +function defaultScheduler(): PucBridgeScheduler { + return frozen({ + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + }); +} + +function validTicket(value: unknown): value is string { + return typeof value === 'string' && LIFECYCLE_TICKET.test(value); +} + +function readMintedTicket(value: unknown): string | undefined { + try { + if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) return undefined; + if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const names = Object.getOwnPropertyNames(value).sort(); + const ok = Object.getOwnPropertyDescriptor(value, 'ok'); + if (!ok || !ok.enumerable || !('value' in ok)) return undefined; + if (ok.value === true && names.length === 2 && names[0] === 'ok' && names[1] === 'value') { + const ticket = Object.getOwnPropertyDescriptor(value, 'value'); + return ticket && ticket.enumerable && 'value' in ticket && validTicket(ticket.value) + ? ticket.value + : undefined; + } + return undefined; + } catch { + return undefined; + } +} + +function readRendererNonceIssue(value: unknown): + | Readonly<{ ok: true; nonce: string }> + | Readonly<{ + ok: false; + reason: 'capability_registry_full' | 'identity_generation_failed' | 'invalid_attempt'; + }> + | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + !Object.isFrozen(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(value).sort(); + if (names.length !== 2) return undefined; + const ok = Object.getOwnPropertyDescriptor(value, 'ok'); + if (!ok || !ok.enumerable || !('value' in ok)) return undefined; + if (ok.value === true && names[0] === 'nonce' && names[1] === 'ok') { + const nonce = Object.getOwnPropertyDescriptor(value, 'nonce'); + return nonce && + nonce.enumerable && + 'value' in nonce && + typeof nonce.value === 'string' && + /^n1_[A-Za-z0-9_-]{22}$/.test(nonce.value) + ? frozen({ ok: true as const, nonce: nonce.value }) + : undefined; + } + if (ok.value === false && names[0] === 'ok' && names[1] === 'reason') { + const reason = Object.getOwnPropertyDescriptor(value, 'reason'); + if ( + reason && + reason.enumerable && + 'value' in reason && + (reason.value === 'capability_registry_full' || + reason.value === 'identity_generation_failed' || + reason.value === 'invalid_attempt') + ) { + return frozen({ ok: false as const, reason: reason.value }); + } + } + return undefined; + } catch { + return undefined; + } +} + function recognizedReservation( reservations: Pick, reservationId: string @@ -140,6 +974,105 @@ function refuse(port: MessagingPort, adId: string): void { } } +function closePort(port: MessagingPort): void { + try { + port.close(); + } catch { + // The adapter facade contains raw close failures and is exact-once. + } +} + +function closeChannel( + channel: Readonly<{ retained: MessagingPort; transferred: MessagingPort }> +): void { + closePort(channel.retained); + closePort(channel.transferred); +} + +function readyResponse( + adId: string, + dynamicOwner: string, + kind: 'aps' | 'adm', + lifecycleTicket: string +): string | undefined { + try { + const owner = Object.create(null) as Record; + owner['version'] = 1; + owner['status'] = TSJS_MESSAGE_PROTOCOL_V1.status.ready; + owner['kind'] = kind; + owner['lifecycleTicket'] = lifecycleTicket; + const response = Object.create(null) as Record; + response['message'] = TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse; + response['adId'] = adId; + response['renderer'] = dynamicOwner; + response['rendererVersion'] = TSJS_MESSAGE_PROTOCOL_V1.rendererVersion; + response['tsOwner'] = owner; + const serialized = Reflect.apply(jsonStringifyIntrinsic, JSON, [response]) as unknown; + return typeof serialized === 'string' && utf8Length(serialized) <= MAX_OUTER_RESPONSE_BYTES + ? serialized + : undefined; + } catch { + return undefined; + } +} + +function ownerResponse(adId: string, lifecycleTicket: string | undefined): string | undefined { + try { + const response = Object.create(null) as Record; + response['message'] = + lifecycleTicket === undefined + ? TSJS_MESSAGE_PROTOCOL_V1.message.ownerRefused + : TSJS_MESSAGE_PROTOCOL_V1.message.ownerRegistered; + response['adId'] = adId; + response['version'] = 1; + if (lifecycleTicket !== undefined) response['lifecycleTicket'] = lifecycleTicket; + const serialized = Reflect.apply(jsonStringifyIntrinsic, JSON, [response]) as unknown; + return typeof serialized === 'string' ? serialized : undefined; + } catch { + return undefined; + } +} + +function ownerSettlement( + lifecycleTicket: string, + outcome: RenderOutcome +): Readonly> | undefined { + try { + const message = Object.create(null) as Record; + message['message'] = TSJS_MESSAGE_PROTOCOL_V1.message.ownerSettled; + message['version'] = 1; + message['lifecycleTicket'] = lifecycleTicket; + if (outcome.outcome === 'accepted') { + message['outcome'] = TSJS_MESSAGE_PROTOCOL_V1.outcome.accepted; + return frozen(message); + } + if (outcome.outcome === 'failed') { + message['outcome'] = TSJS_MESSAGE_PROTOCOL_V1.outcome.failed; + message['reason'] = outcome.reason; + return frozen(message); + } + if (outcome.outcome === 'cancelled') { + message['outcome'] = TSJS_MESSAGE_PROTOCOL_V1.outcome.cancelled; + message['reason'] = outcome.reason; + return frozen(message); + } + return undefined; + } catch { + return undefined; + } +} + +function refuseOwner(port: MessagingPort, adId: string): void { + try { + const response = ownerResponse(adId, undefined); + if (response !== undefined) port.post(response, []); + } catch { + // Refusal transport is best-effort; endpoint closure remains mandatory. + } finally { + closePort(port); + } +} + /** * Own the runtime-wide Universal Creative capture dispatcher. * @@ -147,19 +1080,1099 @@ function refuse(port: MessagingPort, adId: string): void { * malformed or replayed TS capabilities cannot fall through to native Prebid. */ export function createPucBridge(options: PucBridgeOptions): PucBridge { - const messaging = options.messaging; - const reservations = options.reservations; - const pendingClaims = new Map(); + let messaging: MessagingAdapter; + let reservations: PucBridgeOptions['reservations']; + let mintLifecycleTicket: () => IdentityGenerationResult; + let nowSource: () => number; + let publisherOrigin: string | undefined; + let rendererNonces: PucBridgeOptions['rendererNonces']; + let rendererUrl: string | undefined; + let resolveCacheAdm: PucBridgeOptions['resolveCacheAdm']; + let scheduler: PucBridgeScheduler; + try { + messaging = options.messaging; + reservations = options.reservations; + mintLifecycleTicket = options.mintLifecycleTicket ?? mintBrowserLifecycleTicket; + nowSource = options.now ?? defaultNow; + publisherOrigin = options.publisherOrigin; + rendererNonces = options.rendererNonces; + rendererUrl = options.rendererUrl; + resolveCacheAdm = options.resolveCacheAdm; + scheduler = options.scheduler ?? defaultScheduler(); + } catch { + messaging = options.messaging; + reservations = options.reservations; + mintLifecycleTicket = () => frozen({ ok: false, reason: 'identity_generation_failed' }); + nowSource = () => Number.NaN; + publisherOrigin = undefined; + rendererNonces = undefined; + rendererUrl = undefined; + resolveCacheAdm = undefined; + scheduler = defaultScheduler(); + } + let schedulerSet: PucBridgeScheduler['set']; + let schedulerClear: PucBridgeScheduler['clear']; + try { + schedulerSet = scheduler.set; + schedulerClear = scheduler.clear; + } catch { + schedulerSet = () => undefined; + schedulerClear = () => undefined; + } + const dynamicOwnerValid = utf8Length(PUC_DYNAMIC_OWNER) <= MAX_DYNAMIC_OWNER_BYTES; + const attempts = new Map(); + const tickets = new Map(); + let pendingTicketIssues = 0; + let lastNow = Number.NEGATIVE_INFINITY; let disposed = false; + const readNow = (): number | undefined => { + try { + const value = Reflect.apply(nowSource, undefined, []) as number; + if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; + lastNow = value; + return value; + } catch { + return undefined; + } + }; + + const clearScheduled = (handle: unknown): void => { + if (handle === undefined) return; + try { + Reflect.apply(schedulerClear, scheduler, [handle]); + } catch { + // Timer cleanup is best-effort after state is already made inert. + } + }; + + const exactInput = (input: PucGamAttemptInput): PucGamAttemptInput | undefined => { + try { + const reservationId = input.reservationId; + const attempt = input.attempt; + const artifact = input.artifact; + const owner = input.owner; + if ( + typeof reservationId !== 'string' || + !RESERVATION_ID.test(reservationId) || + !ATTEMPT_ID.test(attempt.id) || + attempt.id !== owner.id || + attempt.slot !== owner.slot || + attempt.generation !== owner.generation || + attempt.navigationGeneration !== owner.navigationGeneration || + artifact.kind !== 'puc' || + artifact.attemptId !== attempt.id || + artifact.slot !== owner.slot || + artifact.navigationGeneration !== owner.navigationGeneration || + typeof artifact.dispose !== 'function' || + typeof attempt.beginGamClaim !== 'function' || + typeof attempt.admitClaimedWinner !== 'function' || + typeof attempt.ownerClaimed !== 'function' || + typeof attempt.ownerRegistered !== 'function' || + typeof attempt.beginApsDocument !== 'function' || + typeof attempt.beginAdm !== 'function' || + typeof attempt.apsDocumentAccepted !== 'function' || + typeof attempt.accept !== 'function' || + typeof attempt.cancel !== 'function' || + typeof attempt.fail !== 'function' || + typeof attempt.onSettled !== 'function' || + typeof attempt.snapshot !== 'function' || + typeof owner.isCurrent !== 'function' || + typeof owner.prepareWinnerContext !== 'function' + ) { + return undefined; + } + return frozen({ reservationId, attempt, artifact, owner }); + } catch { + return undefined; + } + }; + + const bindingMatches = (binding: GamAttemptBinding, input: PucGamAttemptInput): boolean => + binding.active && + binding.reservationId === input.reservationId && + binding.attempt === input.attempt && + binding.artifact === input.artifact && + binding.owner === input.owner && + binding.attemptId === input.attempt.id && + binding.slot === input.owner.slot && + binding.navigationGeneration === input.owner.navigationGeneration; + + const currentBindingState = (binding: GamAttemptBinding, expectedState: string): boolean => { + try { + const snapshot = Reflect.apply(binding.attempt.snapshot, binding.attempt, []); + return ( + binding.active && + mapValue(attempts, binding.reservationId) === binding && + binding.attempt.id === binding.attemptId && + binding.attempt.slot === binding.slot && + binding.attempt.navigationGeneration === binding.navigationGeneration && + binding.owner.id === binding.attemptId && + binding.owner.slot === binding.slot && + binding.owner.navigationGeneration === binding.navigationGeneration && + Reflect.apply(binding.owner.isCurrent, binding.owner, []) === true && + snapshot.outcome === undefined && + snapshot.state === expectedState + ); + } catch { + return false; + } + }; + + const tombstoneReservation = (binding: GamAttemptBinding): void => { + try { + const tombstone = reservations.tombstone; + if (typeof tombstone !== 'function') return; + Reflect.apply(tombstone, reservations, [ + frozen({ + reservationId: binding.reservationId, + slot: binding.slot, + navigationGeneration: binding.navigationGeneration, + attemptId: binding.attemptId, + }), + 'stale', + ]); + } catch { + // Attempt settlement stays authoritative if suppression publication fails. + } + }; + + const retireTicket = (binding: GamAttemptBinding): void => { + const ticket = binding.ticket; + if (!ticket) return; + const entry = mapValue(tickets, ticket); + if (entry?.state === 'live' && entry.binding === binding) { + clearScheduled(entry.expiryHandle); + const tombstone: TicketTombstone = { + state: 'tombstone', + expiresAt: entry.expiresAt, + expiryHandle: undefined, + }; + setMapValue(tickets, ticket, tombstone); + const retiredAt = readNow(); + if (retiredAt !== undefined && retiredAt >= tombstone.expiresAt) { + expireTicket(ticket, tombstone, retiredAt); + } else if (retiredAt !== undefined) { + let handle: unknown; + try { + handle = Reflect.apply(schedulerSet, scheduler, [ + () => expireTicket(ticket, tombstone), + tombstone.expiresAt - retiredAt, + ]); + } catch { + handle = undefined; + } + if (mapValue(tickets, ticket) !== tombstone) clearScheduled(handle); + else tombstone.expiryHandle = handle; + } + } else if (entry?.state === 'pending' && entry.binding === binding) { + const retiredAt = readNow(); + if (retiredAt === undefined) { + deleteMapValue(tickets, ticket); + } else { + const tombstone: TicketTombstone = { + state: 'tombstone', + expiresAt: retiredAt + LIFECYCLE_TICKET_TTL_MS, + expiryHandle: undefined, + }; + setMapValue(tickets, ticket, tombstone); + let handle: unknown; + try { + handle = Reflect.apply(schedulerSet, scheduler, [ + () => expireTicket(ticket, tombstone), + LIFECYCLE_TICKET_TTL_MS, + ]); + } catch { + handle = undefined; + } + if (mapValue(tickets, ticket) !== tombstone) { + clearScheduled(handle); + } else { + tombstone.expiryHandle = handle; + } + } + } + binding.ticket = undefined; + }; + + const clearClaimDeadline = (binding: GamAttemptBinding): void => { + const handle = binding.claimDeadlineHandle; + binding.claimDeadlineHandle = undefined; + clearScheduled(handle); + }; + + const disposeOwnedArtifact = (binding: GamAttemptBinding): void => { + if (!binding.artifactOwned) return; + binding.artifactOwned = false; + try { + Reflect.apply(binding.artifact.dispose, binding.artifact, []); + } catch { + // The bridge has already relinquished authority; disposal remains exact-once. + } + }; + + const cleanupBinding = (binding: GamAttemptBinding): void => { + if (!binding.active) return; + binding.active = false; + binding.joining = false; + disposeOwnedArtifact(binding); + clearClaimDeadline(binding); + if (mapValue(attempts, binding.reservationId) === binding) { + deleteMapValue(attempts, binding.reservationId); + } + const claim = binding.claim; + binding.claim = undefined; + if (claim) closePort(claim.port); + const disposeControlListener = binding.controlListenerDispose; + binding.controlListenerDispose = undefined; + if (disposeControlListener) { + try { + disposeControlListener(); + } catch { + // Listener disposal is best-effort after the binding is already inert. + } + } + const disposeDocumentListener = binding.documentListenerDispose; + binding.documentListenerDispose = undefined; + if (disposeDocumentListener) { + try { + disposeDocumentListener(); + } catch { + // Document listener disposal cannot interrupt endpoint cleanup. + } + } + const documentPort = binding.documentPort; + binding.documentPort = undefined; + if (documentPort && !binding.documentPortRegistryOwned) closePort(documentPort); + binding.documentPortRegistryOwned = false; + const documentTransferredPort = binding.documentTransferredPort; + binding.documentTransferredPort = undefined; + if (documentTransferredPort) closePort(documentTransferredPort); + const controlPort = binding.controlPort; + binding.controlPort = undefined; + if (controlPort) closePort(controlPort); + binding.lifecycleTicket = undefined; + binding.nonce = undefined; + binding.pucSource = undefined; + retireTicket(binding); + tombstoneReservation(binding); + }; + + const failBinding = ( + binding: GamAttemptBinding, + reason: RenderFailureReason, + refusePending: boolean + ): void => { + if (!binding.active) return; + if (binding.controlPort && binding.lifecycleTicket) { + try { + Reflect.apply(binding.attempt.fail, binding.attempt, [reason]); + } catch { + // The binding cleanup below remains authoritative. + } + if (binding.active) cleanupBinding(binding); + return; + } + const claim = binding.claim; + binding.claim = undefined; + if (claim) { + if (refusePending) refuse(claim.port, binding.reservationId); + else closePort(claim.port); + } + cleanupBinding(binding); + try { + Reflect.apply(binding.attempt.fail, binding.attempt, [reason]); + } catch { + // The binding is already inert and all owned endpoints are closed. + } + }; + + const settleBinding = (binding: GamAttemptBinding, outcome: RenderOutcome): void => { + if (!binding.active) return; + const controlPort = binding.controlPort; + const lifecycleTicket = binding.lifecycleTicket; + if (controlPort && lifecycleTicket) { + const settlement = ownerSettlement(lifecycleTicket, outcome); + if (settlement) { + try { + controlPort.post(settlement, []); + } catch { + // The remote owner's fixed watchdog contains settlement transport loss. + } + } + } + cleanupBinding(binding); + }; + + const expireTicket = ( + ticket: string, + expected: LiveTicket | TicketTombstone, + observedAt?: number + ): void => { + const entry = mapValue(tickets, ticket); + if (entry !== expected) return; + const now = observedAt ?? readNow(); + if (now === undefined || now < expected.expiresAt) return; + deleteMapValue(tickets, ticket); + if (entry.state === 'live') { + entry.binding.ticket = undefined; + if (entry.binding.active) { + failBinding(entry.binding, 'owner_registration_timeout', false); + } + } + }; + + const pruneExpiredTickets = (now: number): void => { + const entries = snapshotMapEntries(tickets); + for (let index = 0; index < entries.length; index += 1) { + const pair = entries[index]; + if ( + pair && + pair[1].state !== 'pending' && + pair[1].expiresAt <= now && + mapValue(tickets, pair[0]) === pair[1] + ) { + clearScheduled(pair[1].expiryHandle); + expireTicket(pair[0], pair[1], now); + } + } + }; + + const issueTicket = ( + binding: GamAttemptBinding + ): + | Readonly<{ ok: true; ticket: string }> + | Readonly<{ ok: false; reason: RenderFailureReason }> => { + const failure = ( + reason: RenderFailureReason + ): Readonly<{ ok: false; reason: RenderFailureReason }> => + frozen({ ok: false as const, reason }); + const pruneAt = readNow(); + if (pruneAt === undefined) return failure('identity_generation_failed'); + pruneExpiredTickets(pruneAt); + if (mapSize(tickets) + pendingTicketIssues >= MAX_TICKETS) { + return failure('capability_registry_full'); + } + pendingTicketIssues += 1; + try { + for (let draw = 0; draw < MAX_TICKET_DRAWS; draw += 1) { + let minted: unknown; + try { + minted = Reflect.apply(mintLifecycleTicket, undefined, []); + } catch { + return failure('identity_generation_failed'); + } + const ticket = readMintedTicket(minted); + if (!binding.active || disposed) { + return failure('internal_error'); + } + if (!ticket) return failure('identity_generation_failed'); + if (mapValue(tickets, ticket) !== undefined) continue; + if (mapSize(tickets) + pendingTicketIssues > MAX_TICKETS) { + return failure('capability_registry_full'); + } + const entry = frozen({ state: 'pending', binding }); + binding.ticket = ticket; + setMapValue(tickets, ticket, entry); + if (!binding.active || mapValue(tickets, ticket) !== entry || binding.ticket !== ticket) { + if (mapValue(tickets, ticket) === entry) deleteMapValue(tickets, ticket); + if (binding.ticket === ticket) binding.ticket = undefined; + return failure('internal_error'); + } + return frozen({ ok: true, ticket }); + } + return failure('identity_generation_failed'); + } finally { + pendingTicketIssues -= 1; + } + }; + + const activateTicket = (binding: GamAttemptBinding, ticket: string): boolean => { + const pending = mapValue(tickets, ticket); + const postedAt = readNow(); + if ( + pending?.state !== 'pending' || + pending.binding !== binding || + binding.ticket !== ticket || + postedAt === undefined + ) { + return false; + } + const expiresAt = postedAt + LIFECYCLE_TICKET_TTL_MS; + if (!Number.isFinite(expiresAt) || expiresAt <= postedAt) return false; + const live: LiveTicket = { + state: 'live', + binding, + expiresAt, + expiryHandle: undefined, + }; + setMapValue(tickets, ticket, live); + let expiryHandle: unknown; + try { + expiryHandle = Reflect.apply(schedulerSet, scheduler, [ + () => expireTicket(ticket, live), + LIFECYCLE_TICKET_TTL_MS, + ]); + } catch { + expiryHandle = undefined; + } + if ( + expiryHandle === undefined || + !binding.active || + mapValue(tickets, ticket) !== live || + binding.ticket !== ticket + ) { + clearScheduled(expiryHandle); + if (binding.active && binding.ticket === ticket && mapValue(tickets, ticket) === live) { + setMapValue(tickets, ticket, pending); + } else { + if (mapValue(tickets, ticket) === live) deleteMapValue(tickets, ticket); + if (binding.ticket === ticket) binding.ticket = undefined; + } + return false; + } + live.expiryHandle = expiryHandle; + return true; + }; + + const join = (binding: GamAttemptBinding): boolean => { + if ( + !binding.active || + !binding.gamReady || + !binding.claim || + binding.joining || + !currentBindingState(binding, 'waiting_for_gam_and_claim') + ) { + return false; + } + binding.joining = true; + clearClaimDeadline(binding); + const pending = binding.claim; + try { + let claimed: unknown; + try { + claimed = Reflect.apply(reservations.claim, reservations, [ + frozen({ + reservationId: binding.reservationId, + slot: binding.slot, + navigationGeneration: binding.navigationGeneration, + attempt: binding.owner, + pucSource: pending.source, + }), + ]); + } catch { + claimed = undefined; + } + let claimedSuccessfully = false; + try { + claimedSuccessfully = + typeof claimed === 'object' && + claimed !== null && + (claimed as { recognized?: unknown }).recognized === true && + (claimed as { claimed?: unknown }).claimed === true; + } catch { + claimedSuccessfully = false; + } + if ( + !claimedSuccessfully || + Reflect.apply(binding.attempt.admitClaimedWinner, binding.attempt, [claimed]) !== true || + Reflect.apply(binding.attempt.ownerClaimed, binding.attempt, []) !== true || + !currentBindingState(binding, 'waiting_for_owner') + ) { + failBinding(binding, 'bridge_id_mismatch', true); + return false; + } + binding.pucSource = pending.source; + let kind: 'aps' | 'adm'; + try { + const sourceType = binding.attempt.renderSource?.type; + if (sourceType === 'aps') kind = 'aps'; + else if (sourceType === 'adm' || sourceType === 'cache') kind = 'adm'; + else throw new Error('claimed source is unavailable'); + } catch { + failBinding(binding, 'bridge_id_mismatch', true); + return false; + } + const issued = issueTicket(binding); + if (!issued.ok) { + failBinding(binding, issued.reason, true); + return false; + } + const response = readyResponse(binding.reservationId, PUC_DYNAMIC_OWNER, kind, issued.ticket); + if (!response) { + failBinding(binding, 'internal_error', true); + return false; + } + binding.claim = undefined; + let posted = false; + try { + posted = pending.port.post(response, []) === true; + } catch { + posted = false; + } + closePort(pending.port); + if (!posted || !binding.active || !activateTicket(binding, issued.ticket)) { + if (binding.active) failBinding(binding, 'internal_error', false); + return false; + } + return true; + } finally { + if (binding.active) binding.joining = false; + } + }; + + const armClaimDeadline = (binding: GamAttemptBinding): boolean => { + if (!binding.active || binding.claimDeadlineHandle !== undefined) return false; + let handle: unknown; + try { + handle = Reflect.apply(schedulerSet, scheduler, [ + () => { + if ( + binding.active && + binding.gamReady && + !binding.claim && + mapValue(attempts, binding.reservationId) === binding + ) { + binding.claimDeadlineHandle = undefined; + failBinding(binding, 'bridge_claim_timeout', false); + } + }, + CLAIM_DEADLINE_MS, + ]); + } catch { + handle = undefined; + } + if (handle === undefined || !binding.active) { + clearScheduled(handle); + if (binding.active) failBinding(binding, 'internal_error', false); + return false; + } + binding.claimDeadlineHandle = handle; + return true; + }; + + const startAdmOwner = ( + binding: GamAttemptBinding, + lifecycleTicket: string, + resolvedSource?: Readonly + ): boolean => { + const controlPort = binding.controlPort; + if (!controlPort || binding.controlStarted || !binding.active) return false; + let source: unknown; + try { + source = resolvedSource ?? binding.attempt.renderSource; + } catch { + source = undefined; + } + const start = messaging.parseProtocolMessage('admStart', { + message: TSJS_MESSAGE_PROTOCOL_V1.message.admStart, + version: 1, + lifecycleTicket, + source, + }); + if (!start) { + failBinding(binding, 'winner_not_renderable', false); + return false; + } + const receive = (event: unknown): void => { + if (!binding.active || binding.controlPort !== controlPort || !binding.controlStarted) return; + if (!messaging.extractTransferredPorts(event, 0)) { + failBinding(binding, 'internal_error', false); + return; + } + const data = eventData(event); + const inserted = messaging.parseProtocolMessage('ownerInserted', data); + if (inserted?.['lifecycleTicket'] === lifecycleTicket) { + if (binding.ownerInserted) return; + binding.artifactOwned = false; + const began = (() => { + try { + return ( + Reflect.apply(binding.attempt.beginAdm, binding.attempt, [binding.artifact]) === true + ); + } catch { + return false; + } + })(); + if (!began) { + if (binding.active) binding.artifactOwned = true; + failBinding(binding, 'internal_error', false); + return; + } + binding.ownerInserted = true; + return; + } + const loaded = messaging.parseProtocolMessage('admLoaded', data); + if (loaded?.['lifecycleTicket'] === lifecycleTicket) { + if (!binding.ownerInserted) { + failBinding(binding, 'adm_document_no_load', false); + return; + } + const accepted = (() => { + try { + return Reflect.apply(binding.attempt.accept, binding.attempt, []) === true; + } catch { + return false; + } + })(); + if (!accepted && binding.active) failBinding(binding, 'internal_error', false); + return; + } + const failed = messaging.parseProtocolMessage('admFailed', data); + if (failed?.['lifecycleTicket'] === lifecycleTicket) { + failBinding(binding, 'adm_document_no_load', false); + return; + } + failBinding(binding, 'internal_error', false); + }; + const receiveError = (): void => failBinding(binding, 'adm_document_no_load', false); + try { + binding.controlListenerDispose = controlPort.listen(receive, receiveError); + binding.controlStarted = true; + if (controlPort.post(start, []) !== true) { + failBinding(binding, 'internal_error', false); + return false; + } + return binding.active; + } catch { + failBinding(binding, 'internal_error', false); + return false; + } + }; + + const resolveCacheOwner = (binding: GamAttemptBinding, lifecycleTicket: string): boolean => { + if (!binding.active || binding.controlStarted || typeof resolveCacheAdm !== 'function') { + failBinding(binding, 'winner_not_renderable', false); + return false; + } + const resolutionStarted = (() => { + try { + return ( + Reflect.apply(resolveCacheAdm, undefined, [ + binding.attempt, + (source: Readonly): boolean => { + if ( + !binding.active || + binding.controlStarted || + binding.attempt.renderSource?.type !== 'cache' || + !currentBindingState(binding, 'waiting_for_insertion') + ) { + return false; + } + return startAdmOwner(binding, lifecycleTicket, source); + }, + ]) === true + ); + } catch { + return false; + } + })(); + if (!resolutionStarted && binding.active) { + failBinding(binding, 'internal_error', false); + return false; + } + return resolutionStarted; + }; + + const startApsOwner = (binding: GamAttemptBinding, lifecycleTicket: string): boolean => { + const controlPort = binding.controlPort; + const pucSource = binding.pucSource; + if ( + !controlPort || + !pucSource || + binding.controlStarted || + !binding.active || + !rendererNonces || + typeof publisherOrigin !== 'string' || + typeof rendererUrl !== 'string' + ) { + failBinding(binding, 'internal_error', false); + return false; + } + const documentChannel = messaging.createChannel(); + if (!documentChannel) { + failBinding(binding, 'internal_error', false); + return false; + } + if ( + !binding.active || + binding.controlPort !== controlPort || + !currentBindingState(binding, 'waiting_for_insertion') + ) { + closeChannel(documentChannel); + return false; + } + binding.documentPort = documentChannel.retained; + binding.documentTransferredPort = documentChannel.transferred; + binding.documentPortRegistryOwned = true; + let issuedValue: unknown; + try { + issuedValue = Reflect.apply(rendererNonces.issue, rendererNonces, [ + frozen({ + attempt: binding.attempt as unknown as RenderAttempt, + source: pucSource, + port: documentChannel.retained, + }), + ]); + } catch { + issuedValue = undefined; + } + const issued = readRendererNonceIssue(issuedValue); + if (!issued?.ok) { + binding.documentPortRegistryOwned = false; + if (!binding.active) { + closePort(documentChannel.retained); + closePort(documentChannel.transferred); + return false; + } + const reason = issued?.reason; + failBinding( + binding, + reason === 'capability_registry_full' || reason === 'identity_generation_failed' + ? reason + : 'internal_error', + false + ); + return false; + } + if (!binding.active) return false; + const nonce = issued.nonce; + binding.nonce = nonce; + let source: unknown; + try { + source = binding.attempt.renderSource; + } catch { + source = undefined; + } + const start = messaging.parseProtocolMessage('apsStart', { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, + version: 1, + lifecycleTicket, + rendererUrl, + envelope: { + version: 1, + nonce, + publisherOrigin, + renderer: source, + }, + }); + if (!start) { + failBinding(binding, 'winner_not_renderable', false); + return false; + } + const nonceExpectation = () => + frozen({ + nonce, + attempt: binding.attempt as unknown as RenderAttempt, + generation: binding.attempt.generation, + source: pucSource, + port: documentChannel.retained, + }); + const acceptDocument = (): boolean => { + if (!binding.active || binding.documentAccepted || !binding.ownerInserted) return false; + const advanced = (() => { + try { + return ( + Reflect.apply(rendererNonces.consume, rendererNonces, [nonceExpectation()]) === true && + Reflect.apply(binding.attempt.apsDocumentAccepted, binding.attempt, []) === true + ); + } catch { + return false; + } + })(); + if (!advanced) { + failBinding(binding, 'renderer_document_no_load', false); + return false; + } + binding.documentAcceptancePending = false; + binding.documentAccepted = true; + return true; + }; + const receiveDocument = (event: unknown): void => { + if (!binding.active || binding.documentPort !== documentChannel.retained) return; + if (!messaging.extractTransferredPorts(event, 0)) { + failBinding( + binding, + binding.documentAccepted ? 'runner_failed' : 'renderer_document_no_load', + false + ); + return; + } + const data = eventData(event); + const accepted = messaging.parseProtocolMessage('apsDocumentAccepted', data); + if (accepted?.['nonce'] === nonce) { + if (binding.documentAccepted) return; + if (!binding.ownerInserted) { + binding.documentAcceptancePending = true; + return; + } + acceptDocument(); + return; + } + const loaded = messaging.parseProtocolMessage('apsRunnerLoaded', data); + if (loaded?.['nonce'] === nonce) { + if (!binding.documentAccepted && !binding.documentAcceptancePending) { + failBinding(binding, 'renderer_document_no_load', false); + } + return; + } + const completed = messaging.parseProtocolMessage('apsRenderCompleted', data); + if (completed?.['nonce'] === nonce) { + if (!binding.documentAccepted) { + if (binding.documentAcceptancePending) { + binding.documentTerminalPending = 'completed'; + return; + } + failBinding(binding, 'renderer_document_no_load', false); + return; + } + const rendered = (() => { + try { + return Reflect.apply(binding.attempt.accept, binding.attempt, []) === true; + } catch { + return false; + } + })(); + if (!rendered && binding.active) failBinding(binding, 'internal_error', false); + return; + } + const failed = messaging.parseProtocolMessage('apsRenderFailed', data); + if (failed?.['nonce'] === nonce) { + const reason = failed['reason']; + const mapped = + reason === 'descriptor_invalid' || + reason === 'runner_no_load' || + reason === 'runner_failed' + ? reason + : 'winner_not_renderable'; + if (!binding.documentAccepted && binding.documentAcceptancePending) { + binding.documentTerminalPending = mapped; + return; + } + failBinding(binding, mapped, false); + return; + } + failBinding( + binding, + binding.documentAccepted ? 'runner_failed' : 'renderer_document_no_load', + false + ); + }; + const receiveDocumentError = (): void => + failBinding( + binding, + binding.documentAccepted ? 'runner_failed' : 'renderer_document_no_load', + false + ); + const receiveControl = (event: unknown): void => { + if (!binding.active || binding.controlPort !== controlPort || !binding.controlStarted) return; + if (!messaging.extractTransferredPorts(event, 0)) { + failBinding(binding, 'internal_error', false); + return; + } + const inserted = messaging.parseProtocolMessage('ownerInserted', eventData(event)); + if (inserted?.['lifecycleTicket'] !== lifecycleTicket) { + failBinding(binding, 'internal_error', false); + return; + } + if (binding.ownerInserted) return; + binding.artifactOwned = false; + const began = (() => { + try { + return ( + Reflect.apply(binding.attempt.beginApsDocument, binding.attempt, [binding.artifact]) === + true + ); + } catch { + return false; + } + })(); + if (!began) { + if (binding.active) binding.artifactOwned = true; + failBinding(binding, 'internal_error', false); + return; + } + binding.ownerInserted = true; + if (binding.documentAcceptancePending) { + acceptDocument(); + if (!binding.active) return; + const terminal = binding.documentTerminalPending; + binding.documentTerminalPending = undefined; + if (terminal === 'completed') { + const rendered = (() => { + try { + return Reflect.apply(binding.attempt.accept, binding.attempt, []) === true; + } catch { + return false; + } + })(); + if (!rendered && binding.active) failBinding(binding, 'internal_error', false); + } else if (terminal) { + failBinding(binding, terminal, false); + } + } + }; + const receiveControlError = (): void => failBinding(binding, 'internal_error', false); + try { + binding.documentListenerDispose = documentChannel.retained.listen( + receiveDocument, + receiveDocumentError + ); + binding.controlListenerDispose = controlPort.listen(receiveControl, receiveControlError); + binding.controlStarted = true; + if (controlPort.post(start, [documentChannel.transferred]) !== true) { + failBinding(binding, 'internal_error', false); + return false; + } + closePort(documentChannel.transferred); + return binding.active; + } catch { + failBinding(binding, 'internal_error', false); + return false; + } + }; + + const handleOwnerRegistration = ( + event: MessageEvent, + data: unknown, + routing: Readonly<{ message: string; adId?: string; lifecycleTicket?: string }> + ): void => { + const ticket = routing.lifecycleTicket; + if (!ticket) return; + const now = readNow(); + if (now === undefined) return; + pruneExpiredTickets(now); + const entry = mapValue(tickets, ticket); + if (!entry) return; + if (!suppress(event)) return; + + const exact = messaging.parseProtocolMessage('ownerRegister', data); + const inspection = messaging.inspectTransferredPorts(event); + const ports = inspection?.ports; + const responsePort = ports?.[0]; + const exactPort = + inspection?.exactShape === true && inspection.originalCount === 1 && ports?.length === 1; + const closeAdditionalPorts = (): void => { + if (!ports) return; + for (let index = 1; index < ports.length; index += 1) { + const port = ports[index]; + if (port) closePort(port); + } + }; + if (entry.state === 'tombstone') { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + return; + } + + if (entry.state === 'pending') { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + retireTicket(entry.binding); + failBinding(entry.binding, 'bridge_id_mismatch', false); + return; + } + + const binding = entry.binding; + const invalidate = (): void => { + if (responsePort) refuseOwner(responsePort, routing.adId ?? binding.reservationId); + closeAdditionalPorts(); + retireTicket(binding); + failBinding(binding, 'bridge_id_mismatch', false); + }; + if (!exact || !responsePort || !exactPort) { + invalidate(); + return; + } + const source = eventSource(event); + let exactAdId: unknown; + let exactTicket: unknown; + try { + exactAdId = exact['adId']; + exactTicket = exact['lifecycleTicket']; + } catch { + invalidate(); + return; + } + if ( + source === undefined || + source !== binding.pucSource || + exactAdId !== binding.reservationId || + exactTicket !== ticket || + binding.ticket !== ticket || + mapValue(tickets, ticket) !== entry || + !currentBindingState(binding, 'waiting_for_owner') + ) { + invalidate(); + return; + } + + binding.lifecycleTicket = ticket; + retireTicket(binding); + const channel = messaging.createChannel(); + if (!channel) { + refuseOwner(responsePort, binding.reservationId); + failBinding(binding, 'internal_error', false); + return; + } + if (!binding.active || !currentBindingState(binding, 'waiting_for_owner')) { + closeChannel(channel); + refuseOwner(responsePort, binding.reservationId); + if (binding.active) failBinding(binding, 'internal_error', false); + return; + } + binding.controlPort = channel.retained; + const registered = (() => { + try { + return ( + Reflect.apply(binding.attempt.ownerRegistered, binding.attempt, []) === true && + currentBindingState(binding, 'waiting_for_insertion') + ); + } catch { + return false; + } + })(); + const response = registered ? ownerResponse(binding.reservationId, ticket) : undefined; + let posted = false; + if (response) { + try { + posted = responsePort.post(response, [channel.transferred]) === true; + } catch { + posted = false; + } + } + closePort(responsePort); + closePort(channel.transferred); + if (!registered || !posted || !binding.active) { + if (binding.active) failBinding(binding, 'internal_error', false); + return; + } + let sourceType: unknown; + try { + sourceType = binding.attempt.renderSource?.type; + } catch { + sourceType = undefined; + } + if (sourceType === 'adm') { + startAdmOwner(binding, ticket); + } else if (sourceType === 'cache') { + resolveCacheOwner(binding, ticket); + } else if (sourceType === 'aps') { + startApsOwner(binding, ticket); + } else { + failBinding(binding, 'winner_not_renderable', false); + } + }; + const dispatch = (event: MessageEvent): void => { if (disposed) return; const data = eventData(event); const routing = messaging.inspectGlobalMessage(data); - if ( - routing?.message !== TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest || - routing.adId === undefined - ) { + if (routing?.message === TSJS_MESSAGE_PROTOCOL_V1.message.ownerRegister) { + handleOwnerRegistration(event, data, routing); + return; + } + if (routing?.message !== TSJS_MESSAGE_PROTOCOL_V1.message.prebidRequest || !routing.adId) { return; } @@ -168,26 +2181,158 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (!suppress(event)) return; const exact = messaging.parseProtocolMessage('prebidRequest', data); - const ports = messaging.extractTransferredPorts(event, 1); + const inspection = messaging.inspectTransferredPorts(event); + const ports = inspection?.ports; const port = ports?.[0]; - if (!port) return; - if (exact === undefined || recognition.state !== 'renderable') { + if (!inspection || !port) return; + if ( + inspection.exactShape !== true || + inspection.originalCount !== 1 || + ports.length !== 1 || + exact === undefined || + recognition.state !== 'renderable' + ) { refuse(port, routing.adId); + for (let index = 1; index < ports.length; index += 1) { + const extra = ports[index]; + if (extra) closePort(extra); + } return; } const source = eventSource(event); - if (source === undefined || mapValue(pendingClaims, routing.adId) !== undefined) { + const binding = mapValue(attempts, routing.adId); + if ( + source === undefined || + !binding?.active || + binding.claim !== undefined || + !currentBindingState(binding, 'waiting_for_gam_and_claim') + ) { refuse(port, routing.adId); return; } - setMapValue(pendingClaims, routing.adId, frozen({ port, source })); + binding.claim = frozen({ port, source }); + binding.pucSource = source; + if (binding.gamReady) join(binding); }; const uninstall = messaging.installCaptureListener(dispatch); const bridge: PucBridge = { + registerGamAttempt(input): boolean { + if (disposed || !dynamicOwnerValid) return false; + const exact = exactInput(input); + if (!exact || mapValue(attempts, exact.reservationId) !== undefined) return false; + const existing = snapshotMapValues(attempts); + for (let index = 0; index < existing.length; index += 1) { + const candidate = existing[index]; + if ( + candidate && + (candidate.attempt === exact.attempt || + candidate.attempt.generation === exact.attempt.generation) + ) { + return false; + } + } + const recognition = recognizedReservation(reservations, exact.reservationId); + if (recognition?.recognized !== true || recognition.state !== 'renderable') return false; + try { + const snapshot = Reflect.apply(exact.attempt.snapshot, exact.attempt, []); + if ( + Reflect.apply(exact.owner.isCurrent, exact.owner, []) !== true || + snapshot.outcome !== undefined || + snapshot.state !== 'created' || + exact.attempt.renderSource !== undefined + ) { + return false; + } + } catch { + return false; + } + const started = (() => { + try { + return Reflect.apply(exact.attempt.beginGamClaim, exact.attempt, []) === true; + } catch { + return false; + } + })(); + if (!started) return false; + const binding: GamAttemptBinding = { + reservationId: exact.reservationId, + attempt: exact.attempt, + artifact: exact.artifact, + owner: exact.owner, + attemptId: exact.attempt.id, + slot: exact.owner.slot, + navigationGeneration: exact.owner.navigationGeneration, + artifactOwned: true, + active: true, + claim: undefined, + claimDeadlineHandle: undefined, + controlListenerDispose: undefined, + controlPort: undefined, + controlStarted: false, + documentAccepted: false, + documentAcceptancePending: false, + documentTerminalPending: undefined, + documentListenerDispose: undefined, + documentPort: undefined, + documentPortRegistryOwned: false, + documentTransferredPort: undefined, + gamReady: false, + joining: false, + lifecycleTicket: undefined, + nonce: undefined, + ownerInserted: false, + pucSource: undefined, + ticket: undefined, + }; + setMapValue(attempts, exact.reservationId, binding); + if (!currentBindingState(binding, 'waiting_for_gam_and_claim')) { + cleanupBinding(binding); + return false; + } + const observed = (() => { + try { + return ( + Reflect.apply(exact.attempt.onSettled, exact.attempt, [ + (outcome: RenderOutcome) => settleBinding(binding, outcome), + ]) === true + ); + } catch { + return false; + } + })(); + if (!observed || !binding.active || mapValue(attempts, exact.reservationId) !== binding) { + cleanupBinding(binding); + try { + Reflect.apply(exact.attempt.fail, exact.attempt, ['internal_error']); + } catch { + // Registration rejection already retains no bridge authority. + } + return false; + } + return true; + }, + recordNonemptyGam(input): boolean { + if (disposed) return false; + const exact = exactInput(input); + if (!exact) return false; + const binding = mapValue(attempts, exact.reservationId); + if ( + !binding || + !bindingMatches(binding, exact) || + binding.gamReady || + !currentBindingState(binding, 'waiting_for_gam_and_claim') + ) { + return false; + } + binding.gamReady = true; + if (binding.claim) join(binding); + else armClaimDeadline(binding); + return true; + }, dispose(): void { if (disposed) return; disposed = true; @@ -196,20 +2341,51 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { } catch { // Listener removal is already contained by the adapter. } - const claims = snapshotMapValues(pendingClaims); - for (let index = 0; index < claims.length; index += 1) { - const claim = claims[index]; - if (!claim) continue; - try { - claim.port.close(); - } catch { - // Endpoint cleanup is exact-once at the adapter facade. - } + const bindings = snapshotMapValues(attempts); + for (let index = 0; index < bindings.length; index += 1) { + const binding = bindings[index]; + if (!binding) continue; + const cancelled = (() => { + try { + return ( + Reflect.apply(binding.attempt.cancel, binding.attempt, ['navigation_disposed']) === + true + ); + } catch { + return false; + } + })(); + if (!cancelled && binding.active) cleanupBinding(binding); } - Reflect.apply(mapClearIntrinsic, pendingClaims, []); + const ticketEntries = snapshotMapValues(tickets); + for (let index = 0; index < ticketEntries.length; index += 1) { + const entry = ticketEntries[index]; + if (entry?.state !== 'pending') clearScheduled(entry?.expiryHandle); + } + Reflect.apply(mapClearIntrinsic, attempts, []); + Reflect.apply(mapClearIntrinsic, tickets, []); }, snapshotInventoryForTest(): PucBridgeInventory { - return frozen({ disposed, pendingClaims: mapSize(pendingClaims) }); + let pendingClaims = 0; + const bindings = snapshotMapValues(attempts); + for (let index = 0; index < bindings.length; index += 1) { + if (bindings[index]?.claim) pendingClaims += 1; + } + let liveTickets = 0; + let ticketTombstones = 0; + const ticketEntries = snapshotMapValues(tickets); + for (let index = 0; index < ticketEntries.length; index += 1) { + if (ticketEntries[index]?.state === 'live' || ticketEntries[index]?.state === 'pending') { + liveTickets += 1; + } else if (ticketEntries[index]?.state === 'tombstone') ticketTombstones += 1; + } + return frozen({ + attempts: mapSize(attempts), + disposed, + liveTickets, + pendingClaims, + ticketTombstones, + }); }, }; return frozen(bridge); diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index 3d4c02474..a2701f620 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -1214,6 +1214,32 @@ describe('browser messaging adapter', () => { expect(one?.[0]).not.toHaveProperty('postMessage'); }); + it('inspects every available refusal port without treating malformed counts as exact', () => { + const adapter = createBrowserMessagingAdapter(createTarget()); + const first = createPort(); + const second = createPort(); + const third = createPort(); + const malformed = { close: vi.fn() }; + const laterUsable = createPort(); + + const overflow = adapter.inspectTransferredPorts({ ports: [first, second, third] }); + expect(overflow).toMatchObject({ exactShape: true, originalCount: 3 }); + expect(overflow?.ports).toHaveLength(3); + expect(Object.isFrozen(overflow)).toBe(true); + expect(Object.isFrozen(overflow?.ports)).toBe(true); + overflow?.ports.forEach((port) => port.close()); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(third.close).toHaveBeenCalledOnce(); + + const mixed = adapter.inspectTransferredPorts({ ports: [malformed, laterUsable] }); + expect(mixed).toMatchObject({ exactShape: true, originalCount: 2 }); + expect(mixed?.ports).toHaveLength(1); + expect(malformed.close).toHaveBeenCalledOnce(); + mixed?.ports[0]?.close(); + expect(laterUsable.close).toHaveBeenCalledOnce(); + }); + it('closes every transferred port on count mismatch and contains hostile closure', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const first = createPort(); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 485109a14..b55c64705 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -185,14 +185,12 @@ describe('browser composition', () => { adapters: { googletag: fakeGoogletagAdapter(), prebid: fakePrebidAdapter(), - messaging: fakeMessagingAdapter(), + messaging: fakeMessagingAdapter(() => { + order.push('bridge'); + return () => order.push('dispose-bridge'); + }), }, coreActivations: { - bridgeRecognizer: ({ onDispose }, adapters) => { - expect(Object.isFrozen(adapters)).toBe(true); - onDispose(() => order.push('dispose-bridge')); - order.push('bridge'); - }, correctnessGptListeners: ({ onDispose }, adapters) => { expect(Object.isFrozen(adapters)).toBe(true); onDispose(() => order.push('dispose-gpt')); @@ -216,6 +214,7 @@ describe('browser composition', () => { ).toBe(true); await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); expect(order).toEqual(['bridge', 'gpt', 'module']); + expect(composition.pucBridgeForTest()).toBeDefined(); composition.runtime.dispose(); expect(order).toEqual([ @@ -226,6 +225,7 @@ describe('browser composition', () => { 'dispose-gpt', 'dispose-bridge', ]); + expect(composition.pucBridgeForTest()).toBeUndefined(); expect(() => composition.adapters.googletag.run(() => undefined)).toThrowError( expect.objectContaining({ code: 'operation_disposed' }) ); @@ -289,9 +289,6 @@ describe('browser composition', () => { prebid: fakePrebidAdapter(), }, coreActivations: { - bridgeRecognizer: vi.fn(() => { - expect(subscriptions).toEqual([]); - }), correctnessGptListeners: correctness, }, } @@ -339,7 +336,6 @@ describe('browser composition', () => { messaging: fakeMessagingAdapter(), }, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, createIdentityIssuerForTest: () => { @@ -429,7 +425,6 @@ describe('browser composition', () => { }); it('unwinds a lazily-created session when navigation identity generation fails', async () => { - const bridge = vi.fn(); const composition = createTestBrowserRuntimeComposition( { target: {}, @@ -449,7 +444,6 @@ describe('browser composition', () => { }, { coreActivations: { - bridgeRecognizer: bridge, correctnessGptListeners: vi.fn(), }, createIdentityIssuerForTest: () => ({ @@ -466,7 +460,7 @@ describe('browser composition', () => { }); expect(composition.runtimeSessionForTest()).toBeUndefined(); expect(composition.projectionSlotsForTest()).toBeUndefined(); - expect(bridge).not.toHaveBeenCalled(); + expect(composition.pucBridgeForTest()).toBeUndefined(); }); it('releases initial programmatic slots before admitting a replacement SPA projection', async () => { @@ -494,7 +488,6 @@ describe('browser composition', () => { { admittedProgrammaticSlotsForTest: programmaticSlots, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, createIdentityIssuerForTest: () => { @@ -551,7 +544,6 @@ describe('browser composition', () => { { admittedProgrammaticSlotsForTest: Object.freeze(['duplicate', 'duplicate']), coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -601,7 +593,6 @@ describe('browser composition', () => { Array.from({ length: programmaticCount }, (_, index) => `programmatic-${index}`) ), coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -640,7 +631,6 @@ describe('browser composition', () => { { admittedProgrammaticSlotsForTest: programmaticSlots, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -690,7 +680,6 @@ describe('browser composition', () => { prebid: fakePrebidAdapter(), }, coreActivations: { - bridgeRecognizer: vi.fn(), correctnessGptListeners: vi.fn(), }, } @@ -723,7 +712,6 @@ describe('browser composition', () => { })); const adapterActivation = vi.fn(() => 'pending' as const); const listenerActivation = vi.fn(() => vi.fn()); - const timerActivation = vi.fn(() => setTimeout(vi.fn(), 1)); const latePreparation = vi.fn(); const target = {}; const composition = createTestBrowserRuntimeComposition( @@ -759,7 +747,6 @@ describe('browser composition', () => { messaging: fakeMessagingAdapter(listenerActivation), }, coreActivations: { - bridgeRecognizer: timerActivation, correctnessGptListeners: adapterActivation, }, } @@ -786,7 +773,6 @@ describe('browser composition', () => { expect(serviceConstruction).not.toHaveBeenCalled(); expect(adapterActivation).not.toHaveBeenCalled(); expect(listenerActivation).not.toHaveBeenCalled(); - expect(timerActivation).not.toHaveBeenCalled(); expect(latePreparation).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts index ea171ba85..d4002def2 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -1,10 +1,21 @@ import { describe, expect, it, vi } from 'vitest'; import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; -import { createPucBridge } from '../../src/services/puc_bridge'; -import type { ReservationRecognition } from '../../src/services/reservations'; +import { + createPucBridge, + PUC_DYNAMIC_OWNER, + type PucBridgeOptions, + type PucRenderAttempt, +} from '../../src/services/puc_bridge'; +import type { RenderFailureReason, RenderOutcome } from '../../src/services/render'; +import type { + ReservationClaimResult, + ReservationRecognition, + ReservationRenderSource, +} from '../../src/services/reservations'; const RESERVATION_ID = 'r1_abcdefghijklmnopqrstuv'; +const LIFECYCLE_TICKET = 't1_abcdefghijklmnopqrstuv'; function createPort() { return { @@ -24,7 +35,31 @@ function exactRequest(adId = RESERVATION_ID): string { }); } -function createHarness(recognize: (reservationId: unknown) => ReservationRecognition) { +function exactOwnerRegistration(adId: string, lifecycleTicket = LIFECYCLE_TICKET): string { + return JSON.stringify({ + message: 'TS Render Owner Register', + adId, + version: 1, + lifecycleTicket, + }); +} + +interface HarnessOptions { + readonly claim?: PucBridgeOptions['reservations']['claim']; + readonly messageChannel?: new () => { readonly port1: unknown; readonly port2: unknown }; + readonly mintLifecycleTicket?: PucBridgeOptions['mintLifecycleTicket']; + readonly now?: PucBridgeOptions['now']; + readonly publisherOrigin?: string; + readonly rendererNonces?: PucBridgeOptions['rendererNonces']; + readonly rendererUrl?: string; + readonly resolveCacheAdm?: PucBridgeOptions['resolveCacheAdm']; + readonly scheduler?: PucBridgeOptions['scheduler']; +} + +function createHarness( + recognize: (reservationId: unknown) => ReservationRecognition, + options: HarnessOptions = {} +) { let listener: ((event: MessageEvent) => void) | undefined; const target = { addEventListener: vi.fn( @@ -33,11 +68,27 @@ function createHarness(recognize: (reservationId: unknown) => ReservationRecogni } ), removeEventListener: vi.fn(), + ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), }; - const bridge = createPucBridge({ - messaging: createBrowserMessagingAdapter(target), - reservations: { recognize }, - }); + const bridgeOptions: PucBridgeOptions = { + messaging: createBrowserMessagingAdapter(target, { + ...(options.publisherOrigin ? { expectedPublisherOrigin: options.publisherOrigin } : {}), + ...(options.rendererUrl ? { expectedRendererUrl: options.rendererUrl } : {}), + validateApsRenderer: () => true, + }), + reservations: { + claim: options.claim ?? (() => ({ recognized: false }) satisfies ReservationClaimResult), + recognize, + }, + ...(options.mintLifecycleTicket ? { mintLifecycleTicket: options.mintLifecycleTicket } : {}), + ...(options.now ? { now: options.now } : {}), + ...(options.publisherOrigin ? { publisherOrigin: options.publisherOrigin } : {}), + ...(options.rendererNonces ? { rendererNonces: options.rendererNonces } : {}), + ...(options.rendererUrl ? { rendererUrl: options.rendererUrl } : {}), + ...(options.resolveCacheAdm ? { resolveCacheAdm: options.resolveCacheAdm } : {}), + ...(options.scheduler ? { scheduler: options.scheduler } : {}), + }; + const bridge = createPucBridge(bridgeOptions); const dispatch = (event: Record): void => { if (!listener) throw new Error('Expected the capture listener to be installed synchronously'); listener(event as unknown as MessageEvent); @@ -45,7 +96,904 @@ function createHarness(recognize: (reservationId: unknown) => ReservationRecogni return { bridge, dispatch, target }; } +function createGamAttempt(kind: 'aps' | 'adm' | 'cache' = 'aps', index = 0) { + const suffix = index.toString(36).padStart(22, '0').slice(-22); + const id = `a1_${suffix}`; + const reservationId = `r1_${suffix}`; + const navigationGeneration = Object.freeze({ navigation: index }); + const generation = Object.freeze({ attempt: index }); + const winnerContext = Object.freeze({ selectedCpm: 1.25 }); + let state = 'created'; + let outcome: RenderOutcome | undefined; + let renderSource: ReservationRenderSource | undefined; + const settlementObservers: Array<(outcome: RenderOutcome) => void> = []; + const owner = Object.freeze({ + id, + slot: `slot-${index}`, + navigationGeneration, + generation, + winnerContext, + isCurrent: vi.fn(() => outcome === undefined), + prepareWinnerContext: vi.fn(), + }); + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: id, + slot: owner.slot, + navigationGeneration, + dispose: vi.fn(), + }); + const attempt = Object.freeze({ + id, + slot: owner.slot, + generation, + navigationGeneration, + get renderSource() { + return renderSource; + }, + beginGamClaim: vi.fn(() => { + if (state !== 'created' || outcome !== undefined) return false; + state = 'waiting_for_gam_and_claim'; + return true; + }), + admitClaimedWinner: vi.fn(() => { + if (state !== 'waiting_for_gam_and_claim' || outcome !== undefined) return false; + renderSource = Object.freeze( + kind === 'aps' + ? { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + } + : kind === 'adm' + ? { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + } + : { + type: 'cache', + version: 1, + cacheId: '12345678-1234-4123-8123-123456789012', + fetchUrl: + 'https://cache.example/pbc/v1/cache?uuid=12345678-1234-4123-8123-123456789012', + width: 300, + height: 250, + } + ) as ReservationRenderSource; + return true; + }), + ownerClaimed: vi.fn(() => { + if (!renderSource || state !== 'waiting_for_gam_and_claim' || outcome !== undefined) { + return false; + } + state = 'waiting_for_owner'; + return true; + }), + ownerRegistered: vi.fn(() => { + if (state !== 'waiting_for_owner' || outcome !== undefined) return false; + state = 'waiting_for_insertion'; + return true; + }), + beginApsDocument: vi.fn(() => { + if (state !== 'waiting_for_insertion' || outcome !== undefined) return false; + state = 'waiting_for_document'; + return true; + }), + beginAdm: vi.fn(() => { + if (state !== 'waiting_for_insertion' || outcome !== undefined) return false; + state = 'waiting_for_adm'; + return true; + }), + apsDocumentAccepted: vi.fn(() => { + if (state !== 'waiting_for_document' || outcome !== undefined) return false; + state = 'waiting_for_aps_completion'; + return true; + }), + accept: vi.fn(() => { + if ( + (state !== 'waiting_for_aps_completion' && state !== 'waiting_for_adm') || + outcome !== undefined + ) { + return false; + } + outcome = Object.freeze({ outcome: 'accepted' }); + state = 'accepted'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + cancel: vi.fn((reason: 'caller_aborted' | 'superseded' | 'navigation_disposed') => { + if (outcome !== undefined) return false; + outcome = Object.freeze({ outcome: 'cancelled' as const, reason }); + state = 'cancelled'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + fail: vi.fn((reason: RenderFailureReason) => { + if (outcome !== undefined) return false; + outcome = Object.freeze({ outcome: 'failed', reason }); + state = 'failed'; + for (const observer of settlementObservers) observer(outcome); + return true; + }), + onSettled: vi.fn((callback: (terminal: RenderOutcome) => void) => { + if (outcome !== undefined) return false; + settlementObservers.push(callback); + return true; + }), + snapshot: vi.fn(() => Object.freeze({ state, outcome, history: Object.freeze([state]) })), + }); + return { artifact, attempt, owner, reservationId }; +} + +function dispatchPortMessage( + port: ReturnType, + data: unknown, + ports: readonly unknown[] = [] +): void { + const listener = port.addEventListener.mock.calls.find((call) => call[0] === 'message')?.[1] as + ((event: { data: unknown; ports: readonly unknown[] }) => void) | undefined; + if (!listener) throw new Error('Expected the retained port listener to be installed'); + listener({ data, ports }); +} + +function createClock() { + let now = 0; + let nextHandle = 0; + const tasks = new Map void; deadline: number }>(); + const scheduler = { + set: vi.fn((callback: () => void, milliseconds: number): number => { + nextHandle += 1; + tasks.set(nextHandle, { callback, deadline: now + milliseconds }); + return nextHandle; + }), + clear: vi.fn((handle: unknown): void => { + if (typeof handle === 'number') tasks.delete(handle); + }), + }; + const advance = (milliseconds: number): void => { + now += milliseconds; + for (const [handle, task] of [...tasks]) { + if (task.deadline <= now) { + tasks.delete(handle); + task.callback(); + } + } + }; + return { advance, now: () => now, scheduler }; +} + +function issueReadyTicket( + harness: ReturnType, + gam: ReturnType, + source: object +): void { + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [createPort()], + source, + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); +} + describe('Universal Creative bridge dispatcher', () => { + it('installs owner iframe lifecycle handlers before assigning either document source', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const apsStart = PUC_DYNAMIC_OWNER.indexOf('const insertAps'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, apsStart); + const apsOwner = PUC_DYNAMIC_OWNER.slice(apsStart, controlStart); + + expect(new TextEncoder().encode(PUC_DYNAMIC_OWNER).byteLength).toBeLessThanOrEqual(64 * 1_024); + expect(admStart).toBeGreaterThanOrEqual(0); + expect(apsStart).toBeGreaterThan(admStart); + expect(controlStart).toBeGreaterThan(apsStart); + expect(admOwner.indexOf('next.onload =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); + expect(admOwner.indexOf('next.onerror =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); + expect(apsOwner.indexOf('next.onload =')).toBeLessThan(apsOwner.indexOf('next.src =')); + expect(apsOwner.indexOf('next.onerror =')).toBeLessThan(apsOwner.indexOf('next.src =')); + }); + + it('runs the checked-in PUC owner through helper registration and final ADM settlement', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + type: string, + payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + + try { + const ownerData = window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>; + const rendered = dynamicWindow.render!(ownerData, { sendMessage }, window); + expect(sendMessage).toHaveBeenCalledWith( + 'TS Render Owner Register', + { version: 1, lifecycleTicket: LIFECYCLE_TICKET }, + expect.any(Function) + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.start).toHaveBeenCalledOnce(); + + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
remote creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.srcdoc).toContain('
remote creative
'); + expect(frame?.getAttribute('sandbox')).toBe( + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' + ); + expect(controlPort.postMessage).toHaveBeenCalledWith({ + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + + frame?.dispatchEvent(new Event('load')); + expect(controlPort.postMessage).toHaveBeenCalledWith({ + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + + await expect(rendered).resolves.toBeUndefined(); + expect(frame?.isConnected).toBe(true); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + creativeId: 'creative-1', + }, + }, + }, + ports: [documentPort], + }); + + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + expect(frame?.getAttribute('referrerpolicy')).toBe('no-referrer'); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + await expect(rendered).resolves.toBeUndefined(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('keeps APS ownership alive after a local frame error until the kernel settles failure', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }, + }, + }, + ports: [documentPort], + }); + + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + frame?.dispatchEvent(new Event('error')); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('pending'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.close).toHaveBeenCalledOnce(); + expect(controlPort.close).not.toHaveBeenCalled(); + + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'runner_no_load', + }, + ports: [], + }); + await expect(rendered).rejects.toThrow('runner_no_load'); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('fails closed immediately when the PUC helper does not return its disposer', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage: vi.fn(() => undefined) }, + window + ); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('rejects registration at exactly three seconds, disposes the helper, and closes a late port', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const stopListening = vi.fn(); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let rendered: Promise | undefined; + let settlement = 'pending'; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + void rendered.then( + () => { + settlement = 'resolved'; + }, + () => { + settlement = 'rejected'; + } + ); + + await vi.advanceTimersByTimeAsync(2_999); + expect(settlement).toBe('pending'); + expect(stopListening).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settlement).toBe('rejected'); + expect(stopListening).toHaveBeenCalledOnce(); + + const latePort = createPort(); + registrationCallback?.({ data: '{}', ports: [latePort] }); + expect(latePort.close).toHaveBeenCalledOnce(); + expect(stopListening).toHaveBeenCalledOnce(); + } finally { + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('removes uncommitted owner DOM at the exact twenty-second watchdog boundary', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let rendered: Promise | undefined; + let settlement = 'pending'; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + void rendered.then( + () => { + settlement = 'resolved'; + }, + () => { + settlement = 'rejected'; + } + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
uncommitted creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + expect(frame?.isConnected).toBe(true); + + await vi.advanceTimersByTimeAsync(19_999); + expect(settlement).toBe('pending'); + expect(frame?.isConnected).toBe(true); + expect(controlPort.close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(settlement).toBe('rejected'); + expect(frame?.isConnected).toBe(false); + expect(controlPort.close).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + + it('refuses an APS owner start whose renderer URL is outside the publisher origin', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }, + }, + }, + ports: [documentPort], + }); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); + + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + it('installs one capture listener synchronously and removes only that listener on disposal', () => { const harness = createHarness(() => ({ recognized: false })); @@ -53,8 +1001,11 @@ describe('Universal Creative bridge dispatcher', () => { expect(harness.target.addEventListener.mock.calls[0]?.[0]).toBe('message'); expect(harness.target.addEventListener.mock.calls[0]?.[2]).toBe(true); expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, disposed: false, + liveTickets: 0, pendingClaims: 0, + ticketTombstones: 0, }); harness.bridge.dispose(); @@ -63,8 +1014,11 @@ describe('Universal Creative bridge dispatcher', () => { expect(harness.target.removeEventListener.mock.calls[0]?.[0]).toBe('message'); expect(harness.target.removeEventListener.mock.calls[0]?.[2]).toBe(true); expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, disposed: true, + liveTickets: 0, pendingClaims: 0, + ticketTombstones: 0, }); }); @@ -130,7 +1084,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(port.close).toHaveBeenCalledOnce(); }); - it('suppresses recognized requests with the wrong port count and closes every available port', () => { + it('suppresses recognized requests with the wrong port count, refuses on the first, and closes every port', () => { const harness = createHarness(() => ({ recognized: true, state: 'renderable', @@ -138,24 +1092,46 @@ describe('Universal Creative bridge dispatcher', () => { })); const first = createPort(); const second = createPort(); + const third = createPort(); const stopImmediatePropagation = vi.fn(); harness.dispatch({ data: exactRequest(), - ports: [first, second], + ports: [first, second, third], source: Object.freeze({}), stopImmediatePropagation, }); expect(stopImmediatePropagation).toHaveBeenCalledOnce(); - expect(first.postMessage).not.toHaveBeenCalled(); + expect(first.postMessage).toHaveBeenCalledOnce(); + expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '3', + tsOwner: { version: 1, status: 'refused' }, + }); + expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); expect(second.postMessage).not.toHaveBeenCalled(); expect(first.close).toHaveBeenCalledOnce(); expect(second.close).toHaveBeenCalledOnce(); + expect(third.close).toHaveBeenCalledOnce(); + + const malformed = { close: vi.fn() }; + const laterUsable = createPort(); + harness.dispatch({ + data: exactRequest(), + ports: [malformed, laterUsable], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect(malformed.close).toHaveBeenCalledOnce(); + expect(laterUsable.postMessage).toHaveBeenCalledOnce(); + expect(laterUsable.close).toHaveBeenCalledOnce(); expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); }); it('buffers only the first exact live claim and generically refuses a duplicate', () => { + const gam = createGamAttempt('aps'); const harness = createHarness(() => ({ recognized: true, state: 'renderable', @@ -164,6 +1140,14 @@ describe('Universal Creative bridge dispatcher', () => { const first = createPort(); const duplicate = createPort(); const source = Object.freeze({ frame: 'authoritative' }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); harness.dispatch({ data: exactRequest(), @@ -212,4 +1196,1085 @@ describe('Universal Creative bridge dispatcher', () => { expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); } ); + + it('joins an early claim with nonempty GAM and exposes only owner kind and ticket', () => { + const gam = createGamAttempt('aps'); + const source = Object.freeze({ frame: 'authoritative' }); + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + harness.dispatch({ + data: exactRequest(), + ports: [port], + source, + stopImmediatePropagation: vi.fn(), + }); + expect(claim).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + expect(claim).toHaveBeenCalledWith({ + attempt: gam.owner, + navigationGeneration: gam.owner.navigationGeneration, + pucSource: source, + reservationId: RESERVATION_ID, + slot: gam.owner.slot, + }); + expect(gam.attempt.admitClaimedWinner).toHaveBeenCalledOnce(); + expect(gam.attempt.ownerClaimed).toHaveBeenCalledOnce(); + expect(port.postMessage).toHaveBeenCalledOnce(); + const response = JSON.parse(String(port.postMessage.mock.calls[0]?.[0])); + expect( + new TextEncoder().encode(String(port.postMessage.mock.calls[0]?.[0])).byteLength + ).toBeLessThanOrEqual(72 * 1_024); + expect(response).toEqual({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }); + expect(response).not.toHaveProperty('source'); + expect(response).not.toHaveProperty('renderSource'); + expect(response).not.toHaveProperty('winnerContext'); + expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 1, + disposed: false, + liveTickets: 1, + pendingClaims: 0, + ticketTombstones: 0, + }); + + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(harness.bridge.snapshotInventoryForTest()).toEqual({ + attempts: 0, + disposed: false, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 1, + }); + }); + + it('starts the exact three-second claim deadline only after nonempty GAM', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { now: clock.now, scheduler: clock.scheduler } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + + expect(clock.scheduler.set).toHaveBeenCalledWith(expect.any(Function), 3_000); + clock.advance(2_999); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(1); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_claim_timeout'); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + + it('clears a GAM-first claim deadline when the exact request completes the join', () => { + const clock = createClock(); + const gam = createGamAttempt('cache'); + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: RESERVATION_ID, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(gam.attempt.renderSource).toMatchObject({ type: 'cache', version: 1 }); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.kind).toBe('adm'); + expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + clock.advance(2_999); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(1); + expect(gam.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + expect(gam.attempt.fail).not.toHaveBeenCalledWith('bridge_claim_timeout'); + }); + + it('checks all eight ticket draws against live and tombstoned entries', () => { + const first = createGamAttempt('aps', 1); + const second = createGamAttempt('aps', 2); + let draws = 0; + const claim = vi.fn(({ pucSource }: { pucSource: unknown }): ReservationClaimResult => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + })); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim, + mintLifecycleTicket: () => { + draws += 1; + return Object.freeze({ ok: true as const, value: LIFECYCLE_TICKET }); + }, + } + ); + for (const gam of [first, second]) { + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ index: draws }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + if (gam === first) { + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'ready' + ); + expect(gam.attempt.fail('internal_error')).toBe(true); + } else { + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'refused' + ); + expect(gam.attempt.fail).toHaveBeenCalledWith('identity_generation_failed'); + } + } + expect(draws).toBe(9); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + }); + + it('retains ticket tombstones through 2,999 ms and prunes them at 3,000 ms', () => { + const clock = createClock(); + const gam = createGamAttempt('aps', 7); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [createPort()], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + + clock.advance(2_999); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + clock.advance(1); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + }); + + it('starts the fixed ticket TTL only after posting the ready outer response', () => { + const clock = createClock(); + const gam = createGamAttempt('aps', 71); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => clock.advance(1_000)); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + + clock.advance(2_000); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + expect(gam.attempt.fail).not.toHaveBeenCalled(); + clock.advance(999); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + clock.advance(1); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + expect(gam.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + }); + + it('keeps a reused ticket live when a cleared expiry callback from its prior issue arrives late', () => { + let now = 0; + const callbacks: Array<() => void> = []; + const scheduler = { + set: vi.fn((callback: () => void): number => { + callbacks[callbacks.length] = callback; + return callbacks.length; + }), + clear: vi.fn(), + }; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + scheduler, + } + ); + const first = createGamAttempt('aps', 72); + issueReadyTicket(harness, first, Object.freeze({ frame: 'first' })); + const firstExpiry = callbacks[0]; + if (!firstExpiry) throw new Error('Expected the first ticket expiry callback'); + + now = 3_000; + firstExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + + const second = createGamAttempt('aps', 73); + issueReadyTicket(harness, second, Object.freeze({ frame: 'second' })); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + + now = 6_000; + firstExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(1); + expect(second.attempt.fail).not.toHaveBeenCalled(); + + const secondExpiry = callbacks[1]; + if (!secondExpiry) throw new Error('Expected the reused ticket expiry callback'); + secondExpiry(); + expect(harness.bridge.snapshotInventoryForTest().liveTickets).toBe(0); + expect(second.attempt.fail).toHaveBeenCalledWith('owner_registration_timeout'); + }); + + it('fails and tombstones a ticket when the ready outer response cannot be posted', () => { + const gam = createGamAttempt('adm', 8); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + const port = createPort(); + port.postMessage.mockImplementation(() => { + throw new Error('outer response transport failed'); + }); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(port.close).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + ticketTombstones: 1, + }); + }); + + it('shares ticket capacity 320 across live entries without eviction', () => { + const clock = createClock(); + let draw = 0; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => { + const suffix = draw.toString(36).padStart(22, '0').slice(-22); + draw += 1; + return Object.freeze({ ok: true as const, value: `t1_${suffix}` }); + }, + now: clock.now, + scheduler: clock.scheduler, + } + ); + + for (let index = 0; index < 320; index += 1) { + const gam = createGamAttempt('aps', 100 + index); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + const port = createPort(); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ index }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe('ready'); + } + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 320, + liveTickets: 320, + ticketTombstones: 0, + }); + + const overflow = createGamAttempt('aps', 999); + const overflowPort = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: overflow.artifact, + attempt: overflow.attempt, + owner: overflow.owner, + reservationId: overflow.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(overflow.reservationId), + ports: [overflowPort], + source: Object.freeze({ overflow: true }), + stopImmediatePropagation: vi.fn(), + }); + expect( + harness.bridge.recordNonemptyGam({ + artifact: overflow.artifact, + attempt: overflow.attempt, + owner: overflow.owner, + reservationId: overflow.reservationId, + }) + ).toBe(true); + expect(overflow.attempt.fail).toHaveBeenCalledWith('capability_registry_full'); + expect(JSON.parse(String(overflowPort.postMessage.mock.calls[0]?.[0])).tsOwner.status).toBe( + 'refused' + ); + expect(draw).toBe(320); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 320, + liveTickets: 320, + ticketTombstones: 0, + }); + harness.bridge.dispose(); + }); + + it('ignores an unknown owner ticket before suppression, source, or port inspection', () => { + const harness = createHarness(() => ({ recognized: false })); + const stopImmediatePropagation = vi.fn(); + const ports = vi.fn(() => { + throw new Error('unknown ticket ports must not be read'); + }); + const source = vi.fn(() => { + throw new Error('unknown ticket source must not be read'); + }); + + harness.dispatch({ + data: exactOwnerRegistration(RESERVATION_ID, 't1_0000000000000000000000'), + stopImmediatePropagation, + get ports() { + return ports(); + }, + get source() { + return source(); + }, + }); + + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + expect(ports).not.toHaveBeenCalled(); + expect(source).not.toHaveBeenCalled(); + }); + + it('consumes one exact owner registration and retains only the kernel control endpoint', () => { + const gam = createGamAttempt('adm', 1_001); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const retained = createPort(); + const transferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = retained; + readonly port2 = transferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(gam.attempt.ownerRegistered).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Registered', + adId: gam.reservationId, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(responsePort.postMessage.mock.calls[0]?.[1]).toEqual([transferred]); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(transferred.close).not.toHaveBeenCalled(); + expect(retained.close).not.toHaveBeenCalled(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + liveTickets: 0, + ticketTombstones: 1, + }); + + expect(gam.attempt.fail('internal_error')).toBe(true); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).not.toHaveBeenCalled(); + }); + + it('sends exact ADM start and settles only after owner insertion and intended load', () => { + const gam = createGamAttempt('adm', 1_011); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(controlRetained.postMessage).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
fictional creative
', + width: 300, + height: 250, + }, + }, + [], + ]); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.beginAdm).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + dispatchPortMessage(controlRetained, { + message: 'TS ADM Loaded', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage).toHaveBeenCalledTimes(2); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + }); + + it('resolves cache privately and sends only the resulting ADM source to the owner', () => { + const gam = createGamAttempt('cache', 1_013); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + let completeResolution: + | (( + source: Readonly<{ adm: string; height: number; type: 'adm'; version: 1; width: number }> + ) => boolean) + | undefined; + const resolveCacheAdm = vi.fn((_attempt, onResolved) => { + completeResolution = onResolved; + return true; + }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + resolveCacheAdm, + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(resolveCacheAdm).toHaveBeenCalledWith(gam.attempt, expect.any(Function)); + expect(controlRetained.postMessage).not.toHaveBeenCalled(); + expect( + completeResolution?.( + Object.freeze({ + type: 'adm', + version: 1, + adm: '
resolved cache creative
', + width: 300, + height: 250, + }) + ) + ).toBe(true); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
resolved cache creative
', + width: 300, + height: 250, + }, + }, + [], + ]); + expect(JSON.stringify(controlRetained.postMessage.mock.calls[0])).not.toContain('cacheId'); + expect( + completeResolution?.( + Object.freeze({ + type: 'adm', + version: 1, + adm: '
duplicate
', + width: 300, + height: 250, + }) + ) + ).toBe(false); + }); + + it('sends exact APS start with one document port and accepts exact document completion', () => { + const gam = createGamAttempt('aps', 1_012); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + const channels = [ + { port1: controlRetained, port2: controlTransferred }, + { port1: documentRetained, port2: documentTransferred }, + ]; + let channelIndex = 0; + const issue = vi.fn( + (input: { + readonly attempt: PucRenderAttempt; + readonly port: { readonly close: () => void }; + }) => { + expect(input.attempt.onSettled(() => input.port.close())).toBe(true); + return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); + } + ); + const consume = vi.fn(() => true); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + const channel = channels[channelIndex]; + channelIndex += 1; + if (!channel) throw new Error('Unexpected extra MessageChannel'); + this.port1 = channel.port1; + this.port2 = channel.port2; + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(channelIndex).toBe(2); + expect(issue).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[0]).toEqual([ + { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + envelope: { + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + }, + }, + }, + [documentTransferred], + ]); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Document Accepted', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + expect(consume).not.toHaveBeenCalled(); + expect(gam.attempt.apsDocumentAccepted).not.toHaveBeenCalled(); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Runner Loaded', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Completed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + + // Control and document messages travel over different ports, so delivery order + // is not defined even though the owner posts insertion before handing off. + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledOnce(); + expect(consume).toHaveBeenCalledOnce(); + expect(gam.attempt.apsDocumentAccepted).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(documentRetained.close).toHaveBeenCalledOnce(); + expect(controlTransferred.close).not.toHaveBeenCalled(); + expect(documentTransferred.close).not.toHaveBeenCalled(); + }); + + it('suppresses, refuses, and invalidates a live ticket used from the wrong source', () => { + const gam = createGamAttempt('adm', 1_002); + const pucSource = Object.freeze({ frame: 'authoritative' }); + let channels = 0; + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + constructor() { + channels += 1; + } + + readonly port1 = createPort(); + readonly port2 = createPort(); + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const wrongSourcePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [wrongSourcePort], + source: Object.freeze({ frame: 'wrong' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(JSON.parse(String(wrongSourcePort.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + version: 1, + }); + expect(wrongSourcePort.close).toHaveBeenCalledOnce(); + expect(channels).toBe(0); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_id_mismatch'); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + ticketTombstones: 1, + }); + + const replayPort = createPort(); + const stopReplay = vi.fn(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [replayPort], + source: pucSource, + stopImmediatePropagation: stopReplay, + }); + expect(stopReplay).toHaveBeenCalledOnce(); + expect(JSON.parse(String(replayPort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(replayPort.close).toHaveBeenCalledOnce(); + expect(channels).toBe(0); + }); + + it('invalidates a live owner ticket on an extended shape or wrong port count', () => { + const gam = createGamAttempt('aps', 1_003); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const first = createPort(); + const second = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: JSON.stringify({ + message: 'TS Render Owner Register', + adId: gam.reservationId, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + extra: true, + }), + ports: [first, second], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + version: 1, + }); + expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('bridge_id_mismatch'); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(1); + }); + + it('tombstones a posted ticket when its expiry scheduler cannot arm', () => { + let now = 0; + const gam = createGamAttempt('aps', 81); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + scheduler: { + clear: vi.fn(), + set: vi.fn(() => undefined), + }, + } + ); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect(port.postMessage).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + liveTickets: 0, + ticketTombstones: 1, + }); + + now = 3_000; + const latePort = createPort(); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId, LIFECYCLE_TICKET), + ports: [latePort], + source: Object.freeze({}), + stopImmediatePropagation: vi.fn(), + }); + expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); + expect(latePort.postMessage).not.toHaveBeenCalled(); + expect(latePort.close).not.toHaveBeenCalled(); + }); }); diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index 8f41b7d94..b7a1a1675 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -1635,11 +1635,12 @@ collapse those checkpoints or carry unverified behavior between them. - Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` - Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` -- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js` -- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.sha256` -- [ ] **Step 1: Vendor the exact supported PUC 1.17.2 artifact and checksum for hermetic tests;** - the GAM template pins the same version and never `latest`. Add failing tests for +- [ ] **Step 1: Build a locally authored PUC contract harness without copying or vendoring PUC** + **bytes.** Keep it limited to the public `prebidMessenger`, dynamic-renderer, + and `h.sendMessage` behavior exercised by this protocol. The external GAM + configuration selects PUC 1.17.2 and never `latest`; the real-GAM gate, not a + repository artifact, validates that release. Add failing tests for the exact JSON string `{message:"Prebid Request",adId,adServerDomain}`, object/extended shapes, zero/two ports, native id, live/tombstoned TS id, duplicate simultaneous claim, @@ -2750,7 +2751,6 @@ implementation change. - Modify: `crates/trusted-server-integration-tests/browser/helpers/infra.ts` - Modify: `crates/trusted-server-integration-tests/browser/helpers/state.ts` - Modify: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` -- Modify: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js` - Modify: `scripts/integration-tests-browser.sh` - Modify: `.github/workflows/integration-tests.yml` @@ -2763,12 +2763,14 @@ implementation change. `npm --prefix ... exec -- playwright`; it must retain the release-WASM, Viceroy config, Docker image, npm install, and TSJS fixture preparation from Task 0. -- [ ] **Step 2: Create deterministic local GPT and locally authored fictional APS-runner** - success/failure fixtures; run the vendored exact PUC 1.17.2 artifact for the - creative path. The fictional runner must not copy, transform, derive from, or - archive APS runner bytes and must never be packaged as a production fallback. Do - not replace PUC's `prebidMessenger`, `runDynamicRenderer`, or `h.sendMessage` - behavior and do not mock the kernel/services under test. +- [ ] **Step 2: Create deterministic local GPT, PUC-contract, and locally authored** + **fictional APS-runner success/failure fixtures.** The PUC harness implements + only the public `prebidMessenger`, `runDynamicRenderer`, and `h.sendMessage` + behavior required to drive the protocol and contains no copied PUC bytes. The + fictional runner must not copy, transform, derive from, or archive APS runner + bytes and must never be packaged as a production fallback. Do not mock the + kernel/services under test; exercise the actual externally hosted PUC release + only in the real-GAM pre-production gate. - [ ] **Step 3: Implement every spec §7.2 browser-observable race as grouped tables with exact** terminal, DOM, targeting, listener, port, timer, and network assertions: diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index 51751f2f7..bc1cfa365 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -1425,14 +1425,17 @@ artifact object. ### 4.2 Universal Creative claim -The supported GAM creative pins Prebid Universal Creative 1.17.2 by exact artifact, -not `latest` or a publisher-selectable version. Its cross-domain request is a JSON +The supported GAM creative selects Prebid Universal Creative 1.17.2 outside the +Trusted Server source tree, never `latest` or a publisher-selectable version. No PUC +bytes, checksum, or distributable artifact is vendored into this repository. Its +cross-domain request is a JSON string decoding to exactly `{message:"Prebid Request",adId,adServerDomain}` and carries exactly one transferred response port. All three values are strings; `adId` and `adServerDomain` are nonempty. Object-form or extended payloads are rejected. Universal Creative owns -this shape, so it cannot carry a TS nonce. The checked-in hermetic PUC fixture is -generated from or pinned byte-for-byte to the supported source behavior. +this shape, so it cannot carry a TS nonce. Hermetic unit and browser tests exercise a +locally authored contract harness limited to that public message/helper behavior; +the pre-production real-GAM conformance gate exercises the actual PUC release. The bridge is one capture-phase dispatcher installed as the first reversible core effect in the synchronous activation barrier, before any integration-module @@ -3098,7 +3101,7 @@ adding a hidden analytics subsystem here. | Risk | Mitigation | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PUC behavior differs from mocks | vendor and checksum the exact supported PUC 1.17.2 behavior, exercise its `h.sendMessage` channel, and gate on real GAM | +| PUC behavior differs from the local contract harness | keep the harness limited to the public message/helper contract, exercise `h.sendMessage`, and gate the actual externally hosted PUC release on real GAM; do not vendor PUC bytes | | Same-realm publisher code can interfere | explicitly trust TS-authored owner code; capability checks defend unrelated frames, replays, and stale work, not arbitrary same-realm compromise | | A module activation never returns | activation is generated first-party code with boundary tests; elapsed returning calls fail through monotonic checks, but JavaScript cannot preempt a nonreturning same-thread function | | Strict parsing rejects a future APS field | descriptor is versioned; outer transport remains tolerant; add a reviewed version/corpus update rather than silently accepting new semantics | From e2157b046752f82c447e8e515ef413f626fc41d3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:05:29 -0700 Subject: [PATCH 301/494] Harden the serialized PUC owner contract --- .../lib/src/services/puc_bridge.ts | 52 ++++++++++++-- .../lib/test/services/puc_bridge.test.ts | 72 ++++++++++++------- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index 91e0d6d67..bcd345d0d 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -217,6 +217,53 @@ function installPucDynamicOwner(): void { } return true; }; + const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength; + const validApsRenderer = ( + renderer: Record, + publisherOrigin: URL + ): boolean => { + const accountId = renderer['accountId']; + const bidId = renderer['bidId']; + const creativeId = renderer['creativeId']; + const creativeUrl = renderer['creativeUrl']; + const aaxResponse = renderer['aaxResponse']; + if ( + renderer['type'] !== 'aps' || + renderer['version'] !== 1 || + typeof accountId !== 'string' || + accountId.length === 0 || + utf8Length(accountId) > 1024 || + typeof bidId !== 'string' || + bidId.length === 0 || + utf8Length(bidId) > 64 || + /[\x00-\x1f\x7f]/.test(bidId) || + (renderer['tagType'] !== 'iframe' && renderer['tagType'] !== 'script') || + !validDimension(renderer['width']) || + !validDimension(renderer['height']) || + typeof creativeUrl !== 'string' || + utf8Length(creativeUrl) > 4096 || + typeof aaxResponse !== 'string' || + aaxResponse.length > 349_528 || + (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof creativeId !== 'string' || + creativeId.length === 0 || + utf8Length(creativeId) > 1024)) + ) { + return false; + } + try { + const parsedCreativeUrl = new URL(creativeUrl); + return ( + parsedCreativeUrl.protocol === 'https:' && + parsedCreativeUrl.hostname !== '' && + parsedCreativeUrl.username === '' && + parsedCreativeUrl.password === '' && + parsedCreativeUrl.origin !== publisherOrigin.origin + ); + } catch { + return false; + } + }; ownerWindow.render = (data, helper, creativeWindow) => new Promise((resolve, reject) => { @@ -433,12 +480,9 @@ function installPucDynamicOwner(): void { !/^n1_[A-Za-z0-9_-]{22}$/.test(envelope['nonce']) || typeof envelope['publisherOrigin'] !== 'string' || new TextEncoder().encode(envelope['publisherOrigin']).byteLength > 2048 || - renderer['type'] !== 'aps' || - renderer['version'] !== 1 || - !validDimension(renderer['width']) || - !validDimension(renderer['height']) || !parsedUrl || !parsedPublisherOrigin || + !validApsRenderer(renderer, parsedPublisherOrigin) || new TextEncoder().encode(String(rendererUrl)).byteLength > 2048 || (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') || parsedUrl.hostname === '' || diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts index d4002def2..e8fbf8358 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -384,21 +384,23 @@ describe('Universal Creative bridge dispatcher', () => { expect(stopListening).toHaveBeenCalledOnce(); expect(controlPort.start).toHaveBeenCalledOnce(); - controlListener?.({ - data: { - message: 'TS ADM Start', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - source: { - type: 'adm', + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', version: 1, - adm: '
remote creative
', - width: 300, - height: 250, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
remote creative
', + width: 300, + height: 250, + }, }, - }, - ports: [], - }); + ports: [], + }) + ); const frame = document.body.querySelector('iframe'); expect(frame).not.toBeNull(); expect(frame?.srcdoc).toContain('
remote creative
'); @@ -417,15 +419,17 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, }); - controlListener?.({ - data: { - message: 'TS Owner Settled', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - outcome: 'accepted', - }, - ports: [], - }); + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }) + ); await expect(rendered).resolves.toBeUndefined(); expect(frame?.isConnected).toBe(true); @@ -889,7 +893,18 @@ describe('Universal Creative bridge dispatcher', () => { } }); - it('refuses an APS owner start whose renderer URL is outside the publisher origin', async () => { + it.each([ + { + caseName: 'cross-origin renderer route', + rendererOverrides: {}, + rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + }, + { + caseName: 'semantically invalid renderer descriptor', + rendererOverrides: { tagType: 'native' }, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + ])('refuses an APS owner start with a $caseName', async ({ rendererOverrides, rendererUrl }) => { vi.useFakeTimers(); const dynamicWindow = window as unknown as { render?: ( @@ -956,7 +971,7 @@ describe('Universal Creative bridge dispatcher', () => { message: 'TS APS Start', version: 1, lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', + rendererUrl, envelope: { version: 1, nonce: 'n1_abcdefghijklmnopqrstuv', @@ -971,6 +986,7 @@ describe('Universal Creative bridge dispatcher', () => { width: 300, height: 250, aaxResponse: 'renderer-envelope', + ...rendererOverrides, }, }, }, @@ -1866,7 +1882,8 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, }); - expect(gam.attempt.beginAdm).toHaveBeenCalledOnce(); + expect(gam.attempt.beginAdm).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); expect(gam.attempt.accept).not.toHaveBeenCalled(); dispatchPortMessage(controlRetained, { @@ -1875,6 +1892,7 @@ describe('Universal Creative bridge dispatcher', () => { lifecycleTicket: LIFECYCLE_TICKET, }); expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); expect(controlRetained.postMessage).toHaveBeenCalledTimes(2); expect(controlRetained.postMessage.mock.calls[1]).toEqual([ { @@ -2084,7 +2102,8 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, }); - expect(gam.attempt.beginApsDocument).toHaveBeenCalledOnce(); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledWith(gam.artifact); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); expect(consume).toHaveBeenCalledOnce(); expect(gam.attempt.apsDocumentAccepted).toHaveBeenCalledOnce(); expect(gam.attempt.accept).toHaveBeenCalledOnce(); @@ -2101,6 +2120,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(documentRetained.close).toHaveBeenCalledOnce(); expect(controlTransferred.close).not.toHaveBeenCalled(); expect(documentTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); }); it('suppresses, refuses, and invalidates a live ticket used from the wrong source', () => { From 2c61ff684bc7cdc3ac2753e2f072826938ad7bb4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:10:32 -0700 Subject: [PATCH 302/494] Harden PUC owner event boundaries --- .../lib/src/services/puc_bridge.ts | 237 +++++-- .../lib/test/composition/browser.test.ts | 5 +- .../lib/test/services/puc_bridge.test.ts | 654 +++++++++++++++--- 3 files changed, 745 insertions(+), 151 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index bcd345d0d..5ace16d1e 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -117,6 +117,8 @@ function installPucDynamicOwner(): void { 'bundle_partial', ]); const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); + const messageEventDataGetter = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data') + ?.get as ((this: MessageEvent) => unknown) | undefined; const ownDataValue = (candidate: unknown, name: string): unknown => { try { @@ -127,6 +129,16 @@ function installPucDynamicOwner(): void { return undefined; } }; + const eventDataValue = (event: unknown): unknown => { + try { + if (typeof event !== 'object' || event === null) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(event, 'data'); + if (descriptor) return 'value' in descriptor ? descriptor.value : undefined; + return messageEventDataGetter ? Reflect.apply(messageEventDataGetter, event, []) : undefined; + } catch { + return undefined; + } + }; const exactRecord = ( candidate: unknown, @@ -151,37 +163,147 @@ function installPucDynamicOwner(): void { } return candidate as Record; }; - const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { + const snapshotEventPorts = (event: unknown): MessagePort[] | undefined => { try { if (typeof event !== 'object' || event === null) return undefined; const ports = Reflect.get(event, 'ports') as unknown; - if (!Array.isArray(ports) || ports.length !== count) return undefined; - for (let index = 0; index < ports.length; index += 1) { - const port = ports[index] as Partial | undefined; - if (!port || typeof port.postMessage !== 'function' || typeof port.close !== 'function') { + if ( + !Array.isArray(ports) || + Object.getPrototypeOf(ports) !== Array.prototype || + Object.getOwnPropertySymbols(ports).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(ports, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + Object.getOwnPropertyNames(ports).length !== length.value + 1 + ) { + return undefined; + } + const snapshot: MessagePort[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(ports, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + const port = descriptor.value as Partial | undefined; + if ( + !port || + typeof Reflect.get(port, 'postMessage') !== 'function' || + typeof Reflect.get(port, 'close') !== 'function' + ) { return undefined; } + snapshot[index] = port as MessagePort; } - return ports as MessagePort[]; + return snapshot; } catch { return undefined; } }; + const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { + const ports = snapshotEventPorts(event); + return ports?.length === count ? ports : undefined; + }; const closeEventPorts = (event: unknown): void => { - try { - if (typeof event !== 'object' || event === null) return; - const ports = Reflect.get(event, 'ports') as unknown; - if (!Array.isArray(ports)) return; - for (let index = 0; index < ports.length; index += 1) { + const ports = snapshotEventPorts(event); + if (!ports) return; + for (let index = 0; index < ports.length; index += 1) { + try { + ports[index]?.close(); + } catch { + // Late or malformed endpoints are still contained independently. + } + } + }; + const skipJsonWhitespace = (source: string, start: number): number => { + let index = start; + while ( + source[index] === ' ' || + source[index] === '\t' || + source[index] === '\n' || + source[index] === '\r' + ) { + index += 1; + } + return index; + }; + const scanJsonString = (source: string, start: number): number | undefined => { + if (source[start] !== '"') return undefined; + let index = start + 1; + while (index < source.length) { + const character = source[index]; + if (character === '"') return index + 1; + if (character === '\\') { + index += 1; + if (index >= source.length) return undefined; + if (source[index] === 'u') { + if (!/^[0-9a-fA-F]{4}$/.test(source.slice(index + 1, index + 5))) return undefined; + index += 4; + } + } else if (character !== undefined && character.charCodeAt(0) < 0x20) { + return undefined; + } + index += 1; + } + return undefined; + }; + const scanJsonValue = (source: string, start: number): number | undefined => { + let index = skipJsonWhitespace(source, start); + if (source[index] === '"') return scanJsonString(source, index); + if (source[index] === '[') { + index = skipJsonWhitespace(source, index + 1); + if (source[index] === ']') return index + 1; + while (index < source.length) { + const end = scanJsonValue(source, index); + if (end === undefined) return undefined; + index = skipJsonWhitespace(source, end); + if (source[index] === ']') return index + 1; + if (source[index] !== ',') return undefined; + index = skipJsonWhitespace(source, index + 1); + } + return undefined; + } + if (source[index] === '{') { + const keys = new Set(); + index = skipJsonWhitespace(source, index + 1); + if (source[index] === '}') return index + 1; + while (index < source.length) { + const keyEnd = scanJsonString(source, index); + if (keyEnd === undefined) return undefined; + let key: unknown; try { - const port = ports[index] as Partial | undefined; - if (typeof port?.close === 'function') port.close(); + key = JSON.parse(source.slice(index, keyEnd)) as unknown; } catch { - // Late or malformed endpoints are still contained independently. + return undefined; } + if (typeof key !== 'string' || keys.has(key)) return undefined; + keys.add(key); + index = skipJsonWhitespace(source, keyEnd); + if (source[index] !== ':') return undefined; + const valueEnd = scanJsonValue(source, index + 1); + if (valueEnd === undefined) return undefined; + index = skipJsonWhitespace(source, valueEnd); + if (source[index] === '}') return index + 1; + if (source[index] !== ',') return undefined; + index = skipJsonWhitespace(source, index + 1); } + return undefined; + } + const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + source.slice(index) + ); + return match ? index + match[0].length : undefined; + }; + const parseJsonWithoutDuplicateKeys = (source: string): unknown => { + const end = scanJsonValue(source, 0); + if (end === undefined || skipJsonWhitespace(source, end) !== source.length) return undefined; + try { + return JSON.parse(source) as unknown; } catch { - // A hostile event cannot interrupt terminal cleanup. + return undefined; } }; const parseRegistration = (value: unknown): Record | undefined => { @@ -189,7 +311,7 @@ function installPucDynamicOwner(): void { if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 4096) { return undefined; } - return exactRecord(JSON.parse(value) as unknown, [ + return exactRecord(parseJsonWithoutDuplicateKeys(value), [ 'message', 'adId', 'version', @@ -218,10 +340,12 @@ function installPucDynamicOwner(): void { return true; }; const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength; - const validApsRenderer = ( - renderer: Record, - publisherOrigin: URL - ): boolean => { + const containsAsciiControl = (value: string): boolean => + Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }); + const validApsRenderer = (renderer: Record, publisherOrigin: URL): boolean => { const accountId = renderer['accountId']; const bidId = renderer['bidId']; const creativeId = renderer['creativeId']; @@ -236,7 +360,7 @@ function installPucDynamicOwner(): void { typeof bidId !== 'string' || bidId.length === 0 || utf8Length(bidId) > 64 || - /[\x00-\x1f\x7f]/.test(bidId) || + containsAsciiControl(bidId) || (renderer['tagType'] !== 'iframe' && renderer['tagType'] !== 'script') || !validDimension(renderer['width']) || !validDimension(renderer['height']) || @@ -284,6 +408,7 @@ function installPucDynamicOwner(): void { } const adId = outer?.['adId']; const lifecycleTicket = owner?.['lifecycleTicket']; + const ownerKind = owner?.['kind']; if ( !outer || !owner || @@ -311,6 +436,7 @@ function installPucDynamicOwner(): void { let controlPort: MessagePort | undefined; let documentPort: MessagePort | undefined; let frame: HTMLIFrameElement | undefined; + let ownerFrameCurrent: (() => boolean) | undefined; let frameCommitted = false; let localApsFailure = false; let started = false; @@ -406,8 +532,14 @@ function installPucDynamicOwner(): void { } prepareDocument(); const next = configureFrame(source, admSandbox); + const intendedSource = `${source['adm'] as string}`; + ownerFrameCurrent = () => + frame === next && + next.parentNode === creativeWindow.document.body && + next.srcdoc === intendedSource && + next.getAttribute('src') === null; next.onload = () => { - if (!settled && frame === next && next.isConnected) { + if (!settled && ownerFrameCurrent?.() === true) { postControl({ message: 'TS ADM Loaded', version: 1, @@ -424,7 +556,7 @@ function installPucDynamicOwner(): void { }); } }; - next.srcdoc = `${source['adm'] as string}`; + next.srcdoc = intendedSource; frame = next; creativeWindow.document.body.appendChild(next); postControl({ @@ -511,6 +643,13 @@ function installPucDynamicOwner(): void { prepareDocument(); documentPort = ports[0]; const next = configureFrame(renderer, apsSandbox); + const intendedSource = `${parsedUrl.href}#tsaps=${envelope['nonce'] as string}`; + let intendedWindow: Window | null = null; + ownerFrameCurrent = () => + frame === next && + next.parentNode === creativeWindow.document.body && + next.getAttribute('src') === intendedSource && + next.contentWindow === intendedWindow; const containLocalFailure = (transferred?: MessagePort): void => { localApsFailure = true; next.onload = null; @@ -523,7 +662,7 @@ function installPucDynamicOwner(): void { next.remove(); }; next.onload = () => { - if (settled || frame !== next || !next.isConnected || !documentPort) return; + if (settled || ownerFrameCurrent?.() !== true || !documentPort) return; const transferred = documentPort; documentPort = undefined; try { @@ -535,9 +674,10 @@ function installPucDynamicOwner(): void { } }; next.onerror = () => containLocalFailure(); - next.src = `${parsedUrl.href}#tsaps=${envelope['nonce'] as string}`; + next.src = intendedSource; frame = next; creativeWindow.document.body.appendChild(next); + intendedWindow = next.contentWindow; postControl({ message: 'TS Owner Inserted', version: 1, @@ -550,7 +690,7 @@ function installPucDynamicOwner(): void { return; } const ports = eventPorts(event, 0) ?? eventPorts(event, 1); - const dataValue = ownDataValue(event, 'data'); + const dataValue = eventDataValue(event); const routedMessage = ownDataValue(dataValue, 'message'); const routedOutcome = ownDataValue(dataValue, 'outcome'); const message = exactRecord(dataValue, [ @@ -577,18 +717,32 @@ function installPucDynamicOwner(): void { finish(false, 'TS render owner control refused'); return; } - if (message['message'] === 'TS ADM Start' && ports.length === 0 && !started) { + if ( + message['message'] === 'TS ADM Start' && + ownerKind === 'adm' && + ports.length === 0 && + !started + ) { started = true; insertAdm(message['source'] as Record); return; } - if (message['message'] === 'TS APS Start' && ports.length === 1 && !started) { + if ( + message['message'] === 'TS APS Start' && + ownerKind === 'aps' && + ports.length === 1 && + !started + ) { started = true; insertAps(message, ports); return; } if (message['message'] === 'TS Owner Settled' && ports.length === 0) { - if (message['outcome'] === 'accepted' && !localApsFailure && frame && frame.isConnected) { + if ( + message['outcome'] === 'accepted' && + !localApsFailure && + ownerFrameCurrent?.() === true + ) { frameCommitted = true; finish(true, ''); return; @@ -622,13 +776,7 @@ function installPucDynamicOwner(): void { stopHelper(); if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); const ports = eventPorts(event, 1); - let dataValue: unknown; - try { - dataValue = - typeof event === 'object' && event !== null ? Reflect.get(event, 'data') : undefined; - } catch { - dataValue = undefined; - } + const dataValue = eventDataValue(event); const response = parseRegistration(dataValue); if ( !ports || @@ -2083,12 +2231,14 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { ): void => { const ticket = routing.lifecycleTicket; if (!ticket) return; - const now = readNow(); - if (now === undefined) return; - pruneExpiredTickets(now); const entry = mapValue(tickets, ticket); if (!entry) return; if (!suppress(event)) return; + const now = readNow(); + if (now !== undefined) { + pruneExpiredTickets(now); + if (mapValue(tickets, ticket) !== entry) return; + } const exact = messaging.parseProtocolMessage('ownerRegister', data); const inspection = messaging.inspectTransferredPorts(event); @@ -2103,6 +2253,15 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (port) closePort(port); } }; + if (now === undefined) { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + if (entry.state !== 'tombstone') { + retireTicket(entry.binding); + failBinding(entry.binding, 'internal_error', false); + } + return; + } if (entry.state === 'tombstone') { if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); closeAdditionalPorts(); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index b55c64705..17ff5a40f 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -285,7 +285,10 @@ describe('browser composition', () => { { adapters: { googletag, - messaging: fakeMessagingAdapter(), + messaging: fakeMessagingAdapter(() => { + expect(subscriptions).toEqual([]); + return vi.fn(); + }), prebid: fakePrebidAdapter(), }, coreActivations: { diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts index e8fbf8358..67684a8d5 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -313,12 +313,132 @@ describe('Universal Creative bridge dispatcher', () => { expect(admStart).toBeGreaterThanOrEqual(0); expect(apsStart).toBeGreaterThan(admStart); expect(controlStart).toBeGreaterThan(apsStart); - expect(admOwner.indexOf('next.onload =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); - expect(admOwner.indexOf('next.onerror =')).toBeLessThan(admOwner.indexOf('next.srcdoc =')); - expect(apsOwner.indexOf('next.onload =')).toBeLessThan(apsOwner.indexOf('next.src =')); - expect(apsOwner.indexOf('next.onerror =')).toBeLessThan(apsOwner.indexOf('next.src =')); + expect(admOwner.indexOf('next.onload =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(admOwner.indexOf('next.onerror =')).toBeLessThan( + admOwner.indexOf('next.srcdoc = intendedSource;') + ); + expect(apsOwner.indexOf('next.onload =')).toBeLessThan( + apsOwner.indexOf('next.src = intendedSource;') + ); + expect(apsOwner.indexOf('next.onerror =')).toBeLessThan( + apsOwner.indexOf('next.src = intendedSource;') + ); }); + it('binds owner load and final acceptance to the exact inserted navigation', () => { + const admStart = PUC_DYNAMIC_OWNER.indexOf('const insertAdm'); + const apsStart = PUC_DYNAMIC_OWNER.indexOf('const insertAps'); + const controlStart = PUC_DYNAMIC_OWNER.indexOf('const receiveControl'); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, apsStart); + const apsOwner = PUC_DYNAMIC_OWNER.slice(apsStart, controlStart); + + expect(admOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(admOwner).toContain('next.srcdoc === intendedSource'); + expect(admOwner).toContain('next.getAttribute("src") === null'); + expect(apsOwner).toContain('next.parentNode === creativeWindow.document.body'); + expect(apsOwner).toContain('next.getAttribute("src") === intendedSource'); + expect(apsOwner).toContain('next.contentWindow === intendedWindow'); + expect(PUC_DYNAMIC_OWNER).toContain('ownerFrameCurrent?.() === true'); + }); + + it.each(['duplicate registration key', 'accessor-backed registration port'])( + 'rejects a %s without binding its owner channel', + async (caseName) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(_listener: ((event: unknown) => void) | null) {}, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let portAccessorCalls = 0; + let rendered: Promise | undefined; + let observedRejection: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + const ports: unknown[] = [controlPort]; + if (caseName === 'accessor-backed registration port') { + Object.defineProperty(ports, '0', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return controlPort; + }, + }); + } + registrationCallback?.({ + data: + caseName === 'duplicate registration key' + ? `{"message":"TS Render Owner Registered","adId":"${RESERVATION_ID}","version":1,"lifecycleTicket":"${LIFECYCLE_TICKET}","lifecycleTicket":"${LIFECYCLE_TICKET}"}` + : JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports, + }); + + expect(controlPort.start).not.toHaveBeenCalled(); + expect(portAccessorCalls).toBe(0); + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner registration refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + } + ); + it('runs the checked-in PUC owner through helper registration and final ADM settlement', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -372,15 +492,17 @@ describe('Universal Creative bridge dispatcher', () => { { version: 1, lifecycleTicket: LIFECYCLE_TICKET }, expect.any(Function) ); - registrationCallback?.({ - data: JSON.stringify({ - message: 'TS Render Owner Registered', - adId: RESERVATION_ID, - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - }), - ports: [controlPort], - }); + registrationCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort as unknown as MessagePort], + }) + ); expect(stopListening).toHaveBeenCalledOnce(); expect(controlPort.start).toHaveBeenCalledOnce(); @@ -440,6 +562,114 @@ describe('Universal Creative bridge dispatcher', () => { } }); + it('rejects accepted ADM settlement after the owner iframe navigation changes', async () => { + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.( + new MessageEvent('message', { + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort as unknown as MessagePort], + }) + ); + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
intended creative
', + width: 300, + height: 250, + }, + }, + ports: [], + }) + ); + const frame = document.body.querySelector('iframe'); + expect(frame).not.toBeNull(); + if (!frame) throw new Error('Expected owner iframe'); + frame.srcdoc = '
replaced creative
'; + frame.dispatchEvent(new Event('load')); + expect(controlPort.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'TS ADM Loaded' }) + ); + + controlListener?.( + new MessageEvent('message', { + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }) + ); + + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(frame.isConnected).toBe(false); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + it('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -896,119 +1126,130 @@ describe('Universal Creative bridge dispatcher', () => { it.each([ { caseName: 'cross-origin renderer route', + ownerKind: 'aps', rendererOverrides: {}, rendererUrl: 'https://attacker.example/integrations/aps/renderer/v1', }, { caseName: 'semantically invalid renderer descriptor', + ownerKind: 'aps', rendererOverrides: { tagType: 'native' }, rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', }, - ])('refuses an APS owner start with a $caseName', async ({ rendererOverrides, rendererUrl }) => { - vi.useFakeTimers(); - const dynamicWindow = window as unknown as { - render?: ( - data: Readonly>, - helper: Readonly>, - ownerWindow: Window - ) => Promise; - }; - window.eval(PUC_DYNAMIC_OWNER); - let registrationCallback: ((event: unknown) => void) | undefined; - const sendMessage = vi.fn( - ( - _type: string, - _payload: Readonly>, - callback: (event: unknown) => void - ) => { - registrationCallback = callback; - return vi.fn(); - } - ); - let controlListener: ((event: unknown) => void) | undefined; - const controlPort = { - close: vi.fn(), - postMessage: vi.fn(), - start: vi.fn(), - set onmessage(listener: ((event: unknown) => void) | null) { - controlListener = listener ?? undefined; - }, - set onmessageerror(_listener: ((event: unknown) => void) | null) {}, - }; - const documentPort = createPort(); - let rendered: Promise | undefined; - - try { - rendered = dynamicWindow.render!( - window.JSON.parse( - JSON.stringify({ - adId: RESERVATION_ID, - message: 'Prebid Response', - renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', - tsOwner: { - version: 1, - status: 'ready', - kind: 'aps', - lifecycleTicket: LIFECYCLE_TICKET, - }, - }) - ) as Readonly>, - { sendMessage }, - window + { + caseName: 'mismatched declared owner kind', + ownerKind: 'adm', + rendererOverrides: {}, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + }, + ])( + 'refuses an APS owner start with a $caseName', + async ({ ownerKind, rendererOverrides, rendererUrl }) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } ); - registrationCallback?.({ - data: JSON.stringify({ - message: 'TS Render Owner Registered', - adId: RESERVATION_ID, - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - }), - ports: [controlPort], - }); - controlListener?.({ - data: { - message: 'TS APS Start', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl, - envelope: { + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const documentPort = createPort(); + let rendered: Promise | undefined; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: ownerKind, + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer: { - type: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS APS Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + rendererUrl, + envelope: { version: 1, - accountId: 'publisher-account', - bidId: 'bid-1', - tagType: 'iframe', - creativeUrl: 'https://creative.example/render', - width: 300, - height: 250, - aaxResponse: 'renderer-envelope', - ...rendererOverrides, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer: { + type: 'aps', + version: 1, + accountId: 'publisher-account', + bidId: 'bid-1', + tagType: 'iframe', + creativeUrl: 'https://creative.example/render', + width: 300, + height: 250, + aaxResponse: 'renderer-envelope', + ...rendererOverrides, + }, }, }, - }, - ports: [documentPort], - }); - const immediate = rendered.then( - () => 'resolved', - () => 'rejected' - ); + ports: [documentPort], + }); + const immediate = rendered.then( + () => 'resolved', + () => 'rejected' + ); - await Promise.resolve(); - expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); - expect(document.body.querySelector('iframe')).toBeNull(); - expect(documentPort.close).toHaveBeenCalledOnce(); - } finally { - await vi.runAllTimersAsync(); - await rendered?.catch(() => undefined); - vi.useRealTimers(); - delete dynamicWindow.render; - document.body.innerHTML = ''; + await Promise.resolve(); + expect(await Promise.race([immediate, Promise.resolve('pending')])).toBe('rejected'); + expect(document.body.querySelector('iframe')).toBeNull(); + expect(documentPort.close).toHaveBeenCalledOnce(); + } finally { + await vi.runAllTimersAsync(); + await rendered?.catch(() => undefined); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } } - }); + ); it('installs one capture listener synchronously and removes only that listener on disposal', () => { const harness = createHarness(() => ({ recognized: false })); @@ -1772,6 +2013,45 @@ describe('Universal Creative bridge dispatcher', () => { expect(source).not.toHaveBeenCalled(); }); + it('suppresses a known owner ticket before failing closed on a regressed clock', () => { + let now = 100; + const gam = createGamAttempt('adm', 1_009); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: () => now, + } + ); + issueReadyTicket(harness, gam, pucSource); + now = 99; + const responsePort = createPort(); + const stopImmediatePropagation = vi.fn(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation, + }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + }); + it('consumes one exact owner registration and retains only the kernel control endpoint', () => { const gam = createGamAttempt('adm', 1_001); const pucSource = Object.freeze({ frame: 'authoritative' }); @@ -1828,6 +2108,52 @@ describe('Universal Creative bridge dispatcher', () => { expect(transferred.close).not.toHaveBeenCalled(); }); + it('closes both channel endpoints when owner-channel construction settles reentrantly', () => { + const gam = createGamAttempt('adm', 1_010); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const retained = createPort(); + const transferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = retained; + readonly port2 = transferred; + + constructor() { + gam.attempt.fail('internal_error'); + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + const responsePort = createPort(); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [responsePort], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(retained.close).toHaveBeenCalledOnce(); + expect(transferred.close).toHaveBeenCalledOnce(); + expect(responsePort.close).toHaveBeenCalledOnce(); + expect(JSON.parse(String(responsePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + it('sends exact ADM start and settles only after owner insertion and intended load', () => { const gam = createGamAttempt('adm', 1_011); const pucSource = Object.freeze({ frame: 'authoritative' }); @@ -1906,6 +2232,53 @@ describe('Universal Creative bridge dispatcher', () => { expect(controlRetained.close).toHaveBeenCalledOnce(); }); + it('fails closed and contains every port when an owner control message transfers one', () => { + const gam = createGamAttempt('adm', 1_014); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1 = controlRetained; + readonly port2 = controlTransferred; + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + } + ); + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + const unexpected = createPort(); + + dispatchPortMessage( + controlRetained, + { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + [unexpected] + ); + + expect(unexpected.close).toHaveBeenCalledOnce(); + expect(gam.attempt.beginAdm).not.toHaveBeenCalled(); + expect(gam.attempt.fail).toHaveBeenCalledWith('internal_error'); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + }); + it('resolves cache privately and sends only the resulting ADM source to the owner', () => { const gam = createGamAttempt('cache', 1_013); const pucSource = Object.freeze({ frame: 'authoritative' }); @@ -2123,6 +2496,65 @@ describe('Universal Creative bridge dispatcher', () => { expect(gam.artifact.dispose).not.toHaveBeenCalled(); }); + it('closes a reentrant APS document channel before issuing nonce authority', () => { + const gam = createGamAttempt('aps', 1_015); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + let channelIndex = 0; + const issue = vi.fn(); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + channelIndex += 1; + if (channelIndex === 1) { + this.port1 = controlRetained; + this.port2 = controlTransferred; + return; + } + this.port1 = documentRetained; + this.port2 = documentTransferred; + gam.attempt.fail('internal_error'); + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume: vi.fn() }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + expect(channelIndex).toBe(2); + expect(issue).not.toHaveBeenCalled(); + expect(documentRetained.close).toHaveBeenCalledOnce(); + expect(documentTransferred.close).toHaveBeenCalledOnce(); + expect(controlRetained.close).toHaveBeenCalledOnce(); + expect(controlTransferred.close).not.toHaveBeenCalled(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest().attempts).toBe(0); + }); + it('suppresses, refuses, and invalidates a live ticket used from the wrong source', () => { const gam = createGamAttempt('adm', 1_002); const pucSource = Object.freeze({ frame: 'authoritative' }); From 067ed54e309656961818cf2da31bb6c5360464db Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:37:23 -0700 Subject: [PATCH 303/494] Harden Universal Creative race boundaries --- .../lib/src/adapters/messaging.ts | 12 +- .../lib/src/services/puc_bridge.ts | 208 ++++-- .../lib/test/adapters/messaging.test.ts | 8 +- .../lib/test/composition/browser.test.ts | 47 +- .../lib/test/services/puc_bridge.test.ts | 590 +++++++++++++++--- 5 files changed, 707 insertions(+), 158 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index eb8f9c425..18f3734a5 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -218,7 +218,7 @@ export interface MessagingAdapter { targetOrigin: string, transferred: readonly MessagingPort[] ): boolean; - installCaptureListener(listener: CaptureMessageListener): () => void; + installCaptureListener(listener: CaptureMessageListener): (() => void) | undefined; inspectGlobalMessage(candidate: unknown): | Readonly<{ message: string; @@ -1381,16 +1381,16 @@ export function createBrowserMessagingAdapter( return Object.freeze({ createChannel: () => createChannel(target), postWindow, - installCaptureListener(listener: CaptureMessageListener): () => void { + installCaptureListener(listener: CaptureMessageListener): (() => void) | undefined { let add: unknown; let remove: unknown; try { add = Reflect.get(target, 'addEventListener'); remove = Reflect.get(target, 'removeEventListener'); } catch { - return () => undefined; + return undefined; } - if (typeof add !== 'function' || typeof remove !== 'function') return () => undefined; + if (typeof add !== 'function' || typeof remove !== 'function') return undefined; const wrapped: CaptureMessageListener = (event): void => { try { listener(event); @@ -1413,7 +1413,7 @@ export function createBrowserMessagingAdapter( Reflect.apply(add, target, ['message', wrapped, true]); } catch { rollback(); - return () => undefined; + return undefined; } return () => { rollback(); @@ -1432,7 +1432,7 @@ export function createNoopMessagingAdapter(): MessagingAdapter { return Object.freeze({ createChannel: () => undefined, postWindow: () => false, - installCaptureListener: () => () => undefined, + installCaptureListener: () => undefined, inspectGlobalMessage, parseProtocolMessage: (kind: ProtocolMessageKind, candidate: unknown) => parseProtocolMessage(kind, candidate, {}), diff --git a/crates/trusted-server-js/lib/src/services/puc_bridge.ts b/crates/trusted-server-js/lib/src/services/puc_bridge.ts index 5ace16d1e..8ea00a88a 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -119,6 +119,8 @@ function installPucDynamicOwner(): void { const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); const messageEventDataGetter = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data') ?.get as ((this: MessageEvent) => unknown) | undefined; + const messageEventPortsGetter = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'ports') + ?.get as ((this: MessageEvent) => readonly MessagePort[]) | undefined; const ownDataValue = (candidate: unknown, name: string): unknown => { try { @@ -163,56 +165,88 @@ function installPucDynamicOwner(): void { } return candidate as Record; }; - const snapshotEventPorts = (event: unknown): MessagePort[] | undefined => { + const inspectEventPorts = ( + event: unknown + ): + | Readonly<{ + exactShape: boolean; + originalCount: number; + ports: readonly MessagePort[]; + }> + | undefined => { try { if (typeof event !== 'object' || event === null) return undefined; - const ports = Reflect.get(event, 'ports') as unknown; - if ( - !Array.isArray(ports) || - Object.getPrototypeOf(ports) !== Array.prototype || - Object.getOwnPropertySymbols(ports).length !== 0 - ) { - return undefined; - } + const descriptor = Object.getOwnPropertyDescriptor(event, 'ports'); + const ports = descriptor + ? 'value' in descriptor + ? descriptor.value + : undefined + : messageEventPortsGetter + ? Reflect.apply(messageEventPortsGetter, event, []) + : undefined; + if (!Array.isArray(ports)) return undefined; const length = Object.getOwnPropertyDescriptor(ports, 'length'); if ( !length || !('value' in length) || !Number.isSafeInteger(length.value) || - length.value < 0 || - Object.getOwnPropertyNames(ports).length !== length.value + 1 + length.value < 0 ) { return undefined; } + let exactShape = + Object.getPrototypeOf(ports) === Array.prototype && + Object.getOwnPropertySymbols(ports).length === 0 && + Object.getOwnPropertyNames(ports).length === length.value + 1; const snapshot: MessagePort[] = []; + const seen = new Set(); for (let index = 0; index < length.value; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(ports, String(index)); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + exactShape = false; + continue; + } const port = descriptor.value as Partial | undefined; - if ( - !port || - typeof Reflect.get(port, 'postMessage') !== 'function' || - typeof Reflect.get(port, 'close') !== 'function' - ) { - return undefined; + let validPort = false; + try { + validPort = + !!port && + typeof Reflect.get(port, 'postMessage') === 'function' && + typeof Reflect.get(port, 'close') === 'function'; + } catch { + validPort = false; + } + if (!port || !validPort) { + exactShape = false; + continue; + } + const accepted = port as MessagePort; + if (seen.has(accepted)) { + exactShape = false; + continue; } - snapshot[index] = port as MessagePort; + seen.add(accepted); + snapshot[snapshot.length] = accepted; } - return snapshot; + return { exactShape, originalCount: length.value, ports: snapshot }; } catch { return undefined; } }; const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { - const ports = snapshotEventPorts(event); - return ports?.length === count ? ports : undefined; + const inspection = inspectEventPorts(event); + return inspection?.exactShape === true && + inspection.originalCount === count && + inspection.ports.length === count + ? [...inspection.ports] + : undefined; }; const closeEventPorts = (event: unknown): void => { - const ports = snapshotEventPorts(event); - if (!ports) return; - for (let index = 0; index < ports.length; index += 1) { + const inspection = inspectEventPorts(event); + if (!inspection) return; + for (let index = 0; index < inspection.ports.length; index += 1) { try { - ports[index]?.close(); + inspection.ports[index]?.close(); } catch { // Late or malformed endpoints are still contained independently. } @@ -443,8 +477,31 @@ function installPucDynamicOwner(): void { const removeFrameHandlers = (): void => { if (!frame) return; - frame.onload = null; - frame.onerror = null; + try { + frame.onload = null; + } catch { + // One hostile DOM setter cannot skip the remaining terminal cleanup. + } + try { + frame.onerror = null; + } catch { + // One hostile DOM setter cannot skip the remaining terminal cleanup. + } + }; + const clearTimer = (handle: number | undefined): void => { + if (handle === undefined) return; + try { + creativeWindow.clearTimeout(handle); + } catch { + // Timer cleanup cannot prevent channel cleanup or Promise settlement. + } + }; + const removeFrame = (candidate: HTMLIFrameElement | undefined): void => { + try { + candidate?.remove(); + } catch { + // DOM cleanup is best-effort after authority is already terminal. + } }; const closePort = (port: MessagePort | undefined): void => { try { @@ -465,21 +522,33 @@ function installPucDynamicOwner(): void { const finish = (accepted: boolean, reason: string): void => { if (settled) return; settled = true; - if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); - if (ownerTimer !== undefined) creativeWindow.clearTimeout(ownerTimer); - stopHelper(); - removeFrameHandlers(); - if (!accepted && frame && !frameCommitted) frame.remove(); - if (controlPort) { - controlPort.onmessage = null; - controlPort.onmessageerror = null; + try { + clearTimer(registrationTimer); + clearTimer(ownerTimer); + stopHelper(); + removeFrameHandlers(); + if (!accepted && frame && !frameCommitted) removeFrame(frame); + if (controlPort) { + try { + controlPort.onmessage = null; + } catch { + // One hostile handler setter cannot retain the remaining authority. + } + try { + controlPort.onmessageerror = null; + } catch { + // One hostile handler setter cannot retain the remaining authority. + } + } + closePort(documentPort); + closePort(controlPort); + documentPort = undefined; + controlPort = undefined; + ownerFrameCurrent = undefined; + } finally { + if (accepted) resolve(); + else reject(new Error(reason)); } - closePort(documentPort); - closePort(controlPort); - documentPort = undefined; - controlPort = undefined; - if (accepted) resolve(); - else reject(new Error(reason)); }; const postControl = (message: Record): boolean => { try { @@ -652,14 +721,22 @@ function installPucDynamicOwner(): void { next.contentWindow === intendedWindow; const containLocalFailure = (transferred?: MessagePort): void => { localApsFailure = true; - next.onload = null; - next.onerror = null; + try { + next.onload = null; + } catch { + // Local containment continues through hostile DOM setters. + } + try { + next.onerror = null; + } catch { + // Local containment continues through hostile DOM setters. + } closePort(transferred); if (documentPort) { closePort(documentPort); documentPort = undefined; } - next.remove(); + removeFrame(next); }; next.onload = () => { if (settled || ownerFrameCurrent?.() !== true || !documentPort) return; @@ -774,7 +851,7 @@ function installPucDynamicOwner(): void { } registrationFinished = true; stopHelper(); - if (registrationTimer !== undefined) creativeWindow.clearTimeout(registrationTimer); + clearTimer(registrationTimer); const ports = eventPorts(event, 1); const dataValue = eventDataValue(event); const response = parseRegistration(dataValue); @@ -918,6 +995,7 @@ interface GamAttemptBinding { active: boolean; claim: PendingClaim | undefined; claimDeadlineHandle: unknown; + claimDeadlineToken: object | undefined; controlListenerDispose: (() => void) | undefined; controlPort: MessagingPort | undefined; controlStarted: boolean; @@ -1490,6 +1568,7 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { const clearClaimDeadline = (binding: GamAttemptBinding): void => { const handle = binding.claimDeadlineHandle; binding.claimDeadlineHandle = undefined; + binding.claimDeadlineToken = undefined; clearScheduled(handle); }; @@ -1814,18 +1893,22 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { }; const armClaimDeadline = (binding: GamAttemptBinding): boolean => { - if (!binding.active || binding.claimDeadlineHandle !== undefined) return false; + if (!binding.active || binding.claimDeadlineToken !== undefined) return false; + const token = frozen({}); + binding.claimDeadlineToken = token; let handle: unknown; try { handle = Reflect.apply(schedulerSet, scheduler, [ () => { + if (binding.claimDeadlineToken !== token) return; + binding.claimDeadlineToken = undefined; + binding.claimDeadlineHandle = undefined; if ( binding.active && binding.gamReady && !binding.claim && - mapValue(attempts, binding.reservationId) === binding + currentBindingState(binding, 'waiting_for_gam_and_claim') ) { - binding.claimDeadlineHandle = undefined; failBinding(binding, 'bridge_claim_timeout', false); } }, @@ -1834,9 +1917,12 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { } catch { handle = undefined; } - if (handle === undefined || !binding.active) { + if (handle === undefined || !binding.active || binding.claimDeadlineToken !== token) { clearScheduled(handle); - if (binding.active) failBinding(binding, 'internal_error', false); + if (binding.active && binding.claimDeadlineToken === token) { + binding.claimDeadlineToken = undefined; + failBinding(binding, 'internal_error', false); + } return false; } binding.claimDeadlineHandle = handle; @@ -2112,7 +2198,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (completed?.['nonce'] === nonce) { if (!binding.documentAccepted) { if (binding.documentAcceptancePending) { - binding.documentTerminalPending = 'completed'; + if (binding.documentTerminalPending === undefined) { + binding.documentTerminalPending = 'completed'; + } return; } failBinding(binding, 'renderer_document_no_load', false); @@ -2138,7 +2226,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { ? reason : 'winner_not_renderable'; if (!binding.documentAccepted && binding.documentAcceptancePending) { - binding.documentTerminalPending = mapped; + if (binding.documentTerminalPending === undefined) { + binding.documentTerminalPending = mapped; + } return; } failBinding(binding, mapped, false); @@ -2235,9 +2325,10 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (!entry) return; if (!suppress(event)) return; const now = readNow(); + let entryStillCurrent = true; if (now !== undefined) { pruneExpiredTickets(now); - if (mapValue(tickets, ticket) !== entry) return; + entryStillCurrent = mapValue(tickets, ticket) === entry; } const exact = messaging.parseProtocolMessage('ownerRegister', data); @@ -2253,6 +2344,11 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { if (port) closePort(port); } }; + if (!entryStillCurrent) { + if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); + closeAdditionalPorts(); + return; + } if (now === undefined) { if (responsePort) refuseOwner(responsePort, routing.adId ?? ''); closeAdditionalPorts(); @@ -2421,6 +2517,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { }; const uninstall = messaging.installCaptureListener(dispatch); + if (typeof uninstall !== 'function') { + throw new Error('Universal Creative capture listener installation failed'); + } const bridge: PucBridge = { registerGamAttempt(input): boolean { @@ -2473,6 +2572,7 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { active: true, claim: undefined, claimDeadlineHandle: undefined, + claimDeadlineToken: undefined, controlListenerDispose: undefined, controlPort: undefined, controlStarted: false, diff --git a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts index a2701f620..dbcf2eaf3 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -1551,8 +1551,9 @@ describe('browser messaging adapter', () => { const dispose = adapter.installCaptureListener(() => { throw new Error('capture failed'); }); + expect(dispose).toBeTypeOf('function'); expect(() => installed[0]?.({} as MessageEvent)).not.toThrow(); - expect(() => dispose()).not.toThrow(); + expect(() => dispose?.()).not.toThrow(); const raw = createPort(); raw.postMessage.mockImplementation(() => { @@ -1579,7 +1580,7 @@ describe('browser messaging adapter', () => { }, removeEventListener: vi.fn(), }); - expect(() => throwingTarget.installCaptureListener(vi.fn())).not.toThrow(); + expect(throwingTarget.installCaptureListener(vi.fn())).toBeUndefined(); }); it('rolls back the exact capture listener when installation throws after adding it', () => { @@ -1604,8 +1605,7 @@ describe('browser messaging adapter', () => { expect(listeners.size).toBe(0); expect(removeEventListener).toHaveBeenCalledTimes(1); expect(removeEventListener).toHaveBeenCalledWith('message', installed, true); - expect(() => dispose()).not.toThrow(); - expect(() => dispose()).not.toThrow(); + expect(dispose).toBeUndefined(); expect(removeEventListener).toHaveBeenCalledTimes(1); }); }); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 17ff5a40f..1939f954e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -117,14 +117,15 @@ describe('browser composition', () => { const listener = vi.fn(); const dispose = composition.adapters.messaging.installCaptureListener(listener); + expect(dispose).toBeTypeOf('function'); expect(target.addEventListener).toHaveBeenCalledTimes(1); const installed = target.addEventListener.mock.calls[0]?.[1]; expect(installed).toBeTypeOf('function'); expect(target.addEventListener).toHaveBeenCalledWith('message', installed, true); - dispose(); - dispose(); + dispose?.(); + dispose?.(); expect(target.removeEventListener).toHaveBeenCalledTimes(1); expect(target.removeEventListener).toHaveBeenCalledWith('message', installed, true); }); @@ -149,7 +150,8 @@ describe('browser composition', () => { expect(composition.adapters.googletag.bindingStatus()).toBe('pending'); expect(composition.adapters.prebid.bindingStatus()).toBe('pending'); - expect(() => composition.adapters.messaging.installCaptureListener(listener)()).not.toThrow(); + const disposeMessaging = composition.adapters.messaging.installCaptureListener(listener); + expect(disposeMessaging).toBeUndefined(); expect(listener).not.toHaveBeenCalled(); }); @@ -466,6 +468,45 @@ describe('browser composition', () => { expect(composition.pucBridgeForTest()).toBeUndefined(); }); + it('falls back before publishing services when the PUC capture listener cannot install', async () => { + const correctnessGptListeners = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(() => undefined), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners }, + } + ); + + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(correctnessGptListeners).not.toHaveBeenCalled(); + expect(composition.pucBridgeForTest()).toBeUndefined(); + expect(composition.slotServiceForTest()).toBeUndefined(); + }); + it('releases initial programmatic slots before admitting a replacement SPA projection', async () => { let prefix = 0; const programmaticSlots = Object.freeze( diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts index 67684a8d5..d833cde63 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -343,101 +343,240 @@ describe('Universal Creative bridge dispatcher', () => { expect(PUC_DYNAMIC_OWNER).toContain('ownerFrameCurrent?.() === true'); }); - it.each(['duplicate registration key', 'accessor-backed registration port'])( - 'rejects a %s without binding its owner channel', - async (caseName) => { - vi.useFakeTimers(); - const dynamicWindow = window as unknown as { - render?: ( - data: Readonly>, - helper: Readonly>, - ownerWindow: Window - ) => Promise; - }; - window.eval(PUC_DYNAMIC_OWNER); - let registrationCallback: ((event: unknown) => void) | undefined; - const sendMessage = vi.fn( - ( - _type: string, - _payload: Readonly>, - callback: (event: unknown) => void - ) => { - registrationCallback = callback; - return vi.fn(); - } - ); - const controlPort = { - close: vi.fn(), - postMessage: vi.fn(), - start: vi.fn(), - set onmessage(_listener: ((event: unknown) => void) | null) {}, - set onmessageerror(_listener: ((event: unknown) => void) | null) {}, - }; - let portAccessorCalls = 0; - let rendered: Promise | undefined; - let observedRejection: Promise | undefined; + it.each([ + 'duplicate registration key', + 'accessor-backed registration port', + 'accessor-backed registration ports collection', + 'usable registration port before an accessor', + ])('rejects a %s without binding its owner channel', async (caseName) => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(_listener: ((event: unknown) => void) | null) {}, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + let portAccessorCalls = 0; + let rendered: Promise | undefined; + let observedRejection: Promise | undefined; - try { - rendered = dynamicWindow.render!( - window.JSON.parse( - JSON.stringify({ - adId: RESERVATION_ID, - message: 'Prebid Response', - renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', - tsOwner: { + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + const ports: unknown[] = [controlPort]; + if (caseName === 'accessor-backed registration port') { + Object.defineProperty(ports, '0', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return controlPort; + }, + }); + } + if (caseName === 'usable registration port before an accessor') { + ports[1] = undefined; + Object.defineProperty(ports, '1', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return createPort(); + }, + }); + } + const registrationEvent: Record = { + data: + caseName === 'duplicate registration key' + ? `{"message":"TS Render Owner Registered","adId":"${RESERVATION_ID}","version":1,"lifecycleTicket":"${LIFECYCLE_TICKET}","lifecycleTicket":"${LIFECYCLE_TICKET}"}` + : JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, version: 1, - status: 'ready', - kind: 'adm', lifecycleTicket: LIFECYCLE_TICKET, - }, - }) - ) as Readonly>, - { sendMessage }, - window - ); - observedRejection = rendered.then( - () => undefined, - (error: unknown) => error - ); - const ports: unknown[] = [controlPort]; - if (caseName === 'accessor-backed registration port') { - Object.defineProperty(ports, '0', { - configurable: true, - enumerable: true, - get: () => { - portAccessorCalls += 1; - return controlPort; - }, - }); - } - registrationCallback?.({ - data: - caseName === 'duplicate registration key' - ? `{"message":"TS Render Owner Registered","adId":"${RESERVATION_ID}","version":1,"lifecycleTicket":"${LIFECYCLE_TICKET}","lifecycleTicket":"${LIFECYCLE_TICKET}"}` - : JSON.stringify({ - message: 'TS Render Owner Registered', - adId: RESERVATION_ID, - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - }), - ports, + }), + ports, + }; + if (caseName === 'accessor-backed registration ports collection') { + Object.defineProperty(registrationEvent, 'ports', { + configurable: true, + enumerable: true, + get: () => { + portAccessorCalls += 1; + return ports; + }, }); + } + registrationCallback?.(registrationEvent); - expect(controlPort.start).not.toHaveBeenCalled(); - expect(portAccessorCalls).toBe(0); - await expect(observedRejection).resolves.toEqual( - expect.objectContaining({ message: 'TS render owner registration refused' }) - ); - } finally { - await vi.runAllTimersAsync(); - await observedRejection; - vi.useRealTimers(); - delete dynamicWindow.render; - document.body.innerHTML = ''; + expect(controlPort.start).not.toHaveBeenCalled(); + expect(portAccessorCalls).toBe(0); + if ( + caseName === 'duplicate registration key' || + caseName === 'usable registration port before an accessor' + ) { + expect(controlPort.close).toHaveBeenCalledOnce(); } + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner registration refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; } - ); + }); + + it('closes usable control-message ports without reading a later accessor', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + let registrationCallback: ((event: unknown) => void) | undefined; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return vi.fn(); + } + ); + let controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + }; + const usable = createPort(); + let accessorCalls = 0; + let observedRejection: Promise | undefined; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + observedRejection = rendered.then( + () => undefined, + (error: unknown) => error + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + const ports: unknown[] = [usable, undefined]; + Object.defineProperty(ports, '1', { + configurable: true, + enumerable: true, + get: () => { + accessorCalls += 1; + return createPort(); + }, + }); + + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
must not render
', + width: 300, + height: 250, + }, + }, + ports, + }); + + expect(accessorCalls).toBe(0); + expect(usable.close).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + expect(document.body.querySelector('iframe')).toBeNull(); + await expect(observedRejection).resolves.toEqual( + expect.objectContaining({ message: 'TS render owner control refused' }) + ); + } finally { + await vi.runAllTimersAsync(); + await observedRejection; + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); it('runs the checked-in PUC owner through helper registration and final ADM settlement', async () => { const dynamicWindow = window as unknown as { @@ -670,6 +809,153 @@ describe('Universal Creative bridge dispatcher', () => { } }); + it('settles and closes the owner channel when every terminal DOM cleanup hook throws', async () => { + vi.useFakeTimers(); + const dynamicWindow = window as unknown as { + render?: ( + data: Readonly>, + helper: Readonly>, + ownerWindow: Window + ) => Promise; + }; + window.eval(PUC_DYNAMIC_OWNER); + const hostileOwnerWindow = Object.create(window) as Window; + const clearTimeout = vi.fn(() => { + throw new Error('clear timeout failed'); + }); + Object.defineProperties(hostileOwnerWindow, { + clearTimeout: { configurable: true, value: clearTimeout }, + document: { configurable: true, value: document }, + setTimeout: { configurable: true, value: window.setTimeout.bind(window) }, + }); + let registrationCallback: ((event: unknown) => void) | undefined; + const stopListening = vi.fn(); + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + registrationCallback = callback; + return stopListening; + } + ); + let controlListener: ((event: unknown) => void) | undefined; + let throwOnHandlerClear = false; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + start: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + if (listener === null && throwOnHandlerClear) throw new Error('message clear failed'); + controlListener = listener ?? undefined; + }, + set onmessageerror(listener: ((event: unknown) => void) | null) { + if (listener === null && throwOnHandlerClear) { + throw new Error('messageerror clear failed'); + } + }, + }; + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '3', + tsOwner: { + version: 1, + status: 'ready', + kind: 'adm', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + hostileOwnerWindow + ); + const observed = rendered.then( + () => 'resolved', + () => 'rejected' + ); + registrationCallback?.({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + controlListener?.({ + data: { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + source: { + type: 'adm', + version: 1, + adm: '
cleanup test
', + width: 300, + height: 250, + }, + }, + ports: [], + }); + const frame = document.body.querySelector('iframe'); + if (!frame) throw new Error('Expected the owner frame'); + const loadHandler = frame.onload; + const errorHandler = frame.onerror; + Object.defineProperties(frame, { + onerror: { + configurable: true, + get: () => errorHandler, + set: (value: unknown) => { + if (value === null) throw new Error('frame error-handler clear failed'); + }, + }, + onload: { + configurable: true, + get: () => loadHandler, + set: (value: unknown) => { + if (value === null) throw new Error('frame load-handler clear failed'); + }, + }, + remove: { + configurable: true, + value: vi.fn(() => { + throw new Error('frame removal failed'); + }), + }, + }); + throwOnHandlerClear = true; + + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'adm_document_no_load', + }, + ports: [], + }); + + await Promise.resolve(); + expect(await Promise.race([observed, Promise.resolve('pending')])).toBe('rejected'); + expect(clearTimeout).toHaveBeenCalled(); + expect(stopListening).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + delete dynamicWindow.render; + document.body.innerHTML = ''; + } + }); + it('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -1435,6 +1721,111 @@ describe('Universal Creative bridge dispatcher', () => { expect(harness.bridge.snapshotInventoryForTest().pendingClaims).toBe(0); }); + it.each(['caller_aborted', 'superseded', 'navigation_disposed'] as const)( + 'contains a claim-first attempt cancelled as %s', + (reason) => { + const gam = createGamAttempt('aps'); + const harness = createHarness(() => ({ + recognized: true, + state: 'renderable', + expiresAt: 10_000, + })); + const port = createPort(); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + harness.dispatch({ + data: exactRequest(gam.reservationId), + ports: [port], + source: Object.freeze({ frame: 'authoritative' }), + stopImmediatePropagation: vi.fn(), + }); + + expect(gam.attempt.cancel(reason)).toBe(true); + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(port.close).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + }); + } + ); + + it.each(['gam_empty', 'gpt_request_timeout', 'gpt_completion_timeout'] as const)( + 'contains a GAM-first attempt failed as %s and clears its claim deadline', + (reason) => { + const clock = createClock(); + const gam = createGamAttempt('aps'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { now: clock.now, scheduler: clock.scheduler } + ); + expect( + harness.bridge.registerGamAttempt({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + expect( + harness.bridge.recordNonemptyGam({ + artifact: gam.artifact, + attempt: gam.attempt, + owner: gam.owner, + reservationId: gam.reservationId, + }) + ).toBe(true); + + expect(gam.attempt.fail(reason)).toBe(true); + + expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + pendingClaims: 0, + }); + } + ); + + it('tombstones a ready ticket when the owning attempt settles before registration', () => { + const clock = createClock(); + const gam = createGamAttempt('adm'); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource }) => ({ + recognized: true, + claimed: true, + pucSource: pucSource as object, + expiresAt: 10_000, + }), + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + now: clock.now, + scheduler: clock.scheduler, + } + ); + issueReadyTicket(harness, gam, Object.freeze({ frame: 'authoritative' })); + + expect(gam.attempt.cancel('superseded')).toBe(true); + + expect(gam.artifact.dispose).toHaveBeenCalledOnce(); + expect(harness.bridge.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + liveTickets: 0, + ticketTombstones: 1, + }); + }); + it.each(['consumed', 'disposed', 'awaiting_prebid_selection'] as const)( 'suppresses and refuses a recognized non-renderable %s reservation', (state) => { @@ -1614,6 +2005,10 @@ describe('Universal Creative bridge dispatcher', () => { reservationId: RESERVATION_ID, }) ).toBe(true); + const staleClaimDeadline = clock.scheduler.set.mock.calls[0]?.[0]; + if (typeof staleClaimDeadline !== 'function') { + throw new Error('Expected the GAM-first claim deadline callback'); + } const port = createPort(); harness.dispatch({ data: exactRequest(), @@ -1626,6 +2021,8 @@ describe('Universal Creative bridge dispatcher', () => { expect(gam.attempt.renderSource).toMatchObject({ type: 'cache', version: 1 }); expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0])).tsOwner.kind).toBe('adm'); expect(clock.scheduler.clear).toHaveBeenCalledOnce(); + staleClaimDeadline(); + expect(gam.attempt.fail).not.toHaveBeenCalledWith('bridge_claim_timeout'); clock.advance(2_999); expect(gam.attempt.fail).not.toHaveBeenCalled(); clock.advance(1); @@ -2466,6 +2863,12 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, nonce: 'n1_abcdefghijklmnopqrstuv', }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Failed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + reason: 'runner_failed', + }); expect(gam.attempt.accept).not.toHaveBeenCalled(); // Control and document messages travel over different ports, so delivery order @@ -2719,14 +3122,19 @@ describe('Universal Creative bridge dispatcher', () => { now = 3_000; const latePort = createPort(); + const stopImmediatePropagation = vi.fn(); harness.dispatch({ data: exactOwnerRegistration(gam.reservationId, LIFECYCLE_TICKET), ports: [latePort], source: Object.freeze({}), - stopImmediatePropagation: vi.fn(), + stopImmediatePropagation, }); expect(harness.bridge.snapshotInventoryForTest().ticketTombstones).toBe(0); - expect(latePort.postMessage).not.toHaveBeenCalled(); - expect(latePort.close).not.toHaveBeenCalled(); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(JSON.parse(String(latePort.postMessage.mock.calls[0]?.[0]))).toMatchObject({ + message: 'TS Render Owner Refused', + adId: gam.reservationId, + }); + expect(latePort.close).toHaveBeenCalledOnce(); }); }); From 7c0544d79a33d605d3fba55cd03cdc30a0cf211c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:38:18 -0700 Subject: [PATCH 304/494] Cover APS buffered terminal ordering --- .../lib/test/services/puc_bridge.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts index d833cde63..ccaea6d69 100644 --- a/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/services/puc_bridge.test.ts @@ -2899,6 +2899,100 @@ describe('Universal Creative bridge dispatcher', () => { expect(gam.artifact.dispose).not.toHaveBeenCalled(); }); + it('keeps the first buffered APS failure when a later completion arrives', () => { + const gam = createGamAttempt('aps', 1_016); + const pucSource = Object.freeze({ frame: 'authoritative' }); + const controlRetained = createPort(); + const controlTransferred = createPort(); + const documentRetained = createPort(); + const documentTransferred = createPort(); + const channels = [ + { port1: controlRetained, port2: controlTransferred }, + { port1: documentRetained, port2: documentTransferred }, + ]; + let channelIndex = 0; + const issue = vi.fn( + (input: { + readonly attempt: PucRenderAttempt; + readonly port: { readonly close: () => void }; + }) => { + expect(input.attempt.onSettled(() => input.port.close())).toBe(true); + return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); + } + ); + const consume = vi.fn(() => true); + const harness = createHarness( + () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), + { + claim: ({ pucSource: claimedSource }) => ({ + recognized: true, + claimed: true, + pucSource: claimedSource as object, + expiresAt: 10_000, + }), + messageChannel: class { + readonly port1: unknown; + readonly port2: unknown; + + constructor() { + const channel = channels[channelIndex]; + channelIndex += 1; + if (!channel) throw new Error('Unexpected extra MessageChannel'); + this.port1 = channel.port1; + this.port2 = channel.port2; + } + }, + mintLifecycleTicket: () => Object.freeze({ ok: true, value: LIFECYCLE_TICKET }), + publisherOrigin: 'https://publisher.example', + rendererNonces: Object.freeze({ issue, consume }), + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + } + ); + issueReadyTicket(harness, gam, pucSource); + harness.dispatch({ + data: exactOwnerRegistration(gam.reservationId), + ports: [createPort()], + source: pucSource, + stopImmediatePropagation: vi.fn(), + }); + + dispatchPortMessage(documentRetained, { + message: 'TS APS Document Accepted', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Failed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + reason: 'runner_failed', + }); + dispatchPortMessage(documentRetained, { + message: 'TS APS Render Completed', + version: 1, + nonce: 'n1_abcdefghijklmnopqrstuv', + }); + dispatchPortMessage(controlRetained, { + message: 'TS Owner Inserted', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }); + + expect(consume).toHaveBeenCalledOnce(); + expect(gam.attempt.accept).not.toHaveBeenCalled(); + expect(gam.attempt.fail).toHaveBeenCalledWith('runner_failed'); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'failed', + reason: 'runner_failed', + }, + [], + ]); + }); + it('closes a reentrant APS document channel before issuing nonce authority', () => { const gam = createGamAttempt('aps', 1_015); const pucSource = Object.freeze({ frame: 'authoritative' }); From 6404b0b0d07420e0a512f164ae7725d0246c2e56 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:41:46 -0700 Subject: [PATCH 305/494] Implement navigation-owned auction batches --- .../lib/src/services/auction_batch.ts | 564 +++++++++++++++++ .../lib/test/services/auction_batch.test.ts | 566 ++++++++++++++++++ 2 files changed, 1130 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/services/auction_batch.ts create mode 100644 crates/trusted-server-js/lib/test/services/auction_batch.test.ts diff --git a/crates/trusted-server-js/lib/src/services/auction_batch.ts b/crates/trusted-server-js/lib/src/services/auction_batch.ts new file mode 100644 index 000000000..91ce304a9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/services/auction_batch.ts @@ -0,0 +1,564 @@ +import type { NavigationSession, RenderAttemptScope, WinnerContext } from '../kernel/sessions'; + +import type { + RenderAttempt, + RenderAttemptCreationResult, + RenderCancellationReason, + RenderFailureReason, + RenderOutcome, +} from './render'; + +const DEFAULT_AUCTION_ENDPOINT = '/auction'; + +export type AuctionBatchFetcher = (input: string, init: RequestInit) => Promise; + +export interface AuctionBatchBid { + readonly candidateId: string; + readonly rendererReservationId: string; + readonly impid: string; + readonly provider: string; + readonly price: number; + readonly width: number; + readonly height: number; + readonly renderSource: unknown; + readonly adm?: string | undefined; +} + +export type AuctionBatchDecision = + | Readonly<{ slot: string; outcome: 'winner'; candidateId: string }> + | Readonly<{ slot: string; outcome: 'no_bid' }> + | Readonly<{ slot: string; outcome: 'failed'; reason: RenderFailureReason }>; + +export interface ParsedAuctionBatchResponse { + readonly auction: Readonly<{ + readonly results: readonly AuctionBatchDecision[]; + }>; + readonly bids: readonly AuctionBatchBid[]; +} + +export interface AuctionBatchScheduler { + readonly clear: (handle: unknown) => void; + readonly set: (callback: () => void, milliseconds: number) => unknown; +} + +export type AuctionBatchSlotResult = Readonly<{ slot: string; path: 'primary' } & RenderOutcome>; + +export interface AuctionBatchResult { + readonly slots: readonly AuctionBatchSlotResult[]; +} + +export interface AuctionBatch { + readonly result: Promise>; + readonly cancel: () => void; +} + +export interface AuctionBatchInput { + readonly navigation: NavigationSession; + readonly requestBody: string; + readonly signal?: AbortSignal; + readonly slots: readonly string[]; + readonly timeoutMs: number; +} + +export interface AuctionBatchServiceOptions { + readonly cachePolicy?: unknown; + readonly createAttempt: (owner: RenderAttemptScope) => RenderAttemptCreationResult; + readonly endpoint?: string; + readonly fetcher: AuctionBatchFetcher; + readonly parseResponse: ( + value: unknown, + cachePolicy?: unknown + ) => ParsedAuctionBatchResponse | undefined; + readonly renderWinner: (attempt: RenderAttempt, bid: AuctionBatchBid) => boolean; + readonly scheduler?: AuctionBatchScheduler; +} + +export interface AuctionBatchService { + readonly create: (input: AuctionBatchInput) => AuctionBatch; + readonly dispose: () => void; +} + +interface ActiveChild { + readonly attempt: RenderAttempt; + readonly navigationGeneration: object; + terminal: boolean; +} + +interface BatchChild extends ActiveChild { + readonly index: number; + readonly slot: string; +} + +function frozen(value: Value): Readonly { + return Object.freeze(value); +} + +function defaultScheduler(): AuctionBatchScheduler { + return frozen({ + clear: (handle: unknown): void => + globalThis.clearTimeout(handle as ReturnType), + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function terminalResult(slot: string, outcome: RenderOutcome): AuctionBatchSlotResult { + return frozen({ slot, path: 'primary' as const, ...outcome }); +} + +function failedResult(slot: string, reason: RenderFailureReason): AuctionBatchSlotResult { + return terminalResult(slot, frozen({ outcome: 'failed' as const, reason })); +} + +function cancelledResult(slot: string, reason: RenderCancellationReason): AuctionBatchSlotResult { + return terminalResult(slot, frozen({ outcome: 'cancelled' as const, reason })); +} + +function responseMembershipIsExact( + parsed: ParsedAuctionBatchResponse, + slots: readonly string[] +): boolean { + const decisions = parsed.auction.results; + if (decisions.length !== slots.length) return false; + const membership = new Set(slots); + if (membership.size !== slots.length) return false; + const observed = new Set(); + for (let index = 0; index < decisions.length; index += 1) { + const slot = decisions[index]?.slot; + if (!slot || !membership.has(slot) || observed.has(slot)) return false; + observed.add(slot); + } + return observed.size === membership.size; +} + +/** Runtime-owned coordinator for navigation-scoped one-fetch auction batches. */ +export function createAuctionBatchService( + options: AuctionBatchServiceOptions +): AuctionBatchService { + const endpoint = options.endpoint ?? DEFAULT_AUCTION_ENDPOINT; + const parseResponse = options.parseResponse; + const scheduler = options.scheduler ?? defaultScheduler(); + const activeByNavigation = new Map>(); + const batches = new Set void }>>(); + let nextBatchOrdinal = 0; + let disposed = false; + + const activeSlots = (generation: object): Map => { + const existing = activeByNavigation.get(generation); + if (existing) return existing; + const created = new Map(); + activeByNavigation.set(generation, created); + return created; + }; + + const create = (input: AuctionBatchInput): AuctionBatch => { + const slots = frozen(Array.from(input.slots)); + const results: Array = new Array(slots.length); + let resolveResult: (value: Readonly) => void = () => undefined; + const result = new Promise>((resolve) => { + resolveResult = resolve; + }); + const immediate = (reason: RenderCancellationReason): AuctionBatch => { + const terminal = frozen({ + slots: frozen(slots.map((slot) => cancelledResult(slot, reason))), + }); + resolveResult(terminal); + return frozen({ result, cancel: () => undefined }); + }; + + if (disposed || !input.navigation.isCurrent()) return immediate('navigation_disposed'); + nextBatchOrdinal += 1; + const owner = input.navigation.createAuctionBatch(`auction-batch-${nextBatchOrdinal}`); + if (!owner) return immediate('navigation_disposed'); + + const children: Array = new Array(slots.length); + const navigationGeneration = input.navigation.generation; + const navigationSlots = activeSlots(navigationGeneration); + const controller = new AbortController(); + let callerListener: (() => void) | undefined; + let deadlineHandle: unknown; + let deadlineArmed = false; + let fetchPending = false; + let finished = false; + let building = true; + let remaining = slots.length; + + const clearDeadline = (): void => { + if (!deadlineArmed) return; + deadlineArmed = false; + const handle = deadlineHandle; + deadlineHandle = undefined; + try { + scheduler.clear(handle); + } catch { + // The logical deadline is already inert. + } + }; + + const abortFetch = (): void => { + if (!fetchPending) return; + fetchPending = false; + try { + controller.abort(); + } catch { + // Child outcomes remain authoritative if host abort throws. + } + }; + + const cleanupSignal = (): void => { + if (!callerListener || !input.signal) return; + try { + input.signal.removeEventListener('abort', callerListener); + } catch { + // A hostile signal cannot retain batch authority. + } + callerListener = undefined; + }; + + const finishIfComplete = (): void => { + if (finished || building || remaining !== 0) return; + finished = true; + clearDeadline(); + abortFetch(); + cleanupSignal(); + const membership = activeByNavigation.get(navigationGeneration); + if (membership?.size === 0) activeByNavigation.delete(navigationGeneration); + batches.delete(batchControl); + try { + owner.dispose(); + } catch { + // All public children are already terminal. + } + resolveResult( + frozen({ + slots: frozen( + results.map( + (entry, index) => entry ?? failedResult(slots[index] ?? '', 'internal_error') + ) + ), + }) + ); + }; + + const settleIndex = (index: number, terminal: AuctionBatchSlotResult): void => { + if (results[index]) return; + results[index] = terminal; + remaining -= 1; + const child = children[index]; + if (child) { + child.terminal = true; + if (navigationSlots.get(child.slot) === child) navigationSlots.delete(child.slot); + } + finishIfComplete(); + }; + + const cancelLive = (reason: RenderCancellationReason): void => { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.terminal) continue; + let cancelled: boolean; + try { + cancelled = child.attempt.cancel(reason) === true; + } catch { + cancelled = false; + } + if (!cancelled && !child.terminal) { + settleIndex(index, cancelledResult(child.slot, reason)); + } + } + finishIfComplete(); + }; + + const batchControl = frozen({ cancel: cancelLive }); + batches.add(batchControl); + + for (let index = 0; index < slots.length; index += 1) { + const slot = slots[index]; + if (!slot) { + settleIndex(index, failedResult('', 'internal_error')); + continue; + } + const previous = navigationSlots.get(slot); + if (previous && !previous.terminal) { + try { + previous.attempt.cancel('superseded'); + } catch { + // Exact removal below decides whether the new child may proceed. + } + } + if (navigationSlots.get(slot) === previous && previous && !previous.terminal) { + settleIndex(index, failedResult(slot, 'internal_error')); + continue; + } + + const issued = owner.createRenderAttempt(slot); + if (!issued.ok) { + settleIndex( + index, + issued.reason === 'identity_generation_failed' + ? failedResult(slot, 'identity_generation_failed') + : issued.reason === 'stale_owner' + ? cancelledResult(slot, 'navigation_disposed') + : failedResult(slot, 'internal_error') + ); + continue; + } + let created: RenderAttemptCreationResult; + try { + created = options.createAttempt(issued.value); + } catch { + created = frozen({ ok: false, reason: 'invalid_attempt' as const }); + } + if (!created.ok) { + try { + issued.value.dispose(); + } catch { + // The failed construction owns no public result authority. + } + settleIndex( + index, + created.reason === 'identity_generation_failed' + ? failedResult(slot, 'identity_generation_failed') + : created.reason === 'stale_owner' + ? cancelledResult(slot, 'navigation_disposed') + : failedResult(slot, 'internal_error') + ); + continue; + } + const child: BatchChild = { + attempt: created.value, + index, + navigationGeneration, + slot, + terminal: false, + }; + children[index] = child; + navigationSlots.set(slot, child); + let observing: boolean; + try { + observing = + created.value.onSettled((outcome) => + settleIndex(index, terminalResult(slot, outcome)) + ) === true; + } catch { + observing = false; + } + if (!observing && !child.terminal) { + try { + created.value.fail('internal_error'); + } catch { + settleIndex(index, failedResult(slot, 'internal_error')); + } + } + } + building = false; + + const publicBatch = frozen({ + result, + cancel: (): void => cancelLive('caller_aborted'), + }); + + if (remaining === 0) { + finishIfComplete(); + return publicBatch; + } + if (input.signal?.aborted === true) { + cancelLive('caller_aborted'); + return publicBatch; + } + if (input.signal) { + callerListener = (): void => cancelLive('caller_aborted'); + try { + input.signal.addEventListener('abort', callerListener, { once: true }); + } catch { + cancelLive('caller_aborted'); + return publicBatch; + } + if (Reflect.get(input.signal, 'aborted') === true) { + cancelLive('caller_aborted'); + return publicBatch; + } + } + + const failLive = (reason: RenderFailureReason): void => { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.terminal) continue; + try { + if (child.attempt.fail(reason) !== true && !child.terminal) { + settleIndex(index, failedResult(child.slot, reason)); + } + } catch { + settleIndex(index, failedResult(child.slot, reason)); + } + } + finishIfComplete(); + }; + + const completeTransport = (): void => { + fetchPending = false; + clearDeadline(); + }; + + const applyResponse = (parsed: ParsedAuctionBatchResponse): void => { + const bids = new Map(parsed.bids.map((bid) => [bid.candidateId, bid])); + const decisions = new Map( + parsed.auction.results.map((decision) => [decision.slot, decision]) + ); + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || child.terminal) continue; + const decision = decisions.get(child.slot); + if (!decision) { + child.attempt.fail('invalid_response'); + continue; + } + if (decision.outcome === 'no_bid') { + child.attempt.noBid(); + continue; + } + if (decision.outcome === 'failed') { + child.attempt.fail(decision.reason); + continue; + } + const bid = bids.get(decision.candidateId); + const context: WinnerContext | undefined = bid + ? frozen({ selectedCpm: bid.price }) + : undefined; + let admitted: boolean; + try { + admitted = + !!bid && + !!context && + child.attempt.admitDirectWinner(bid.renderSource, context) === true; + } catch { + admitted = false; + } + if (!admitted || !bid) { + if (!child.terminal) child.attempt.fail('winner_not_renderable'); + continue; + } + let rendering: boolean; + try { + rendering = options.renderWinner(child.attempt, bid) === true; + } catch { + rendering = false; + } + if (!rendering && !child.terminal) child.attempt.fail('winner_not_renderable'); + } + }; + + const processFetch = async (fetchResult: Promise): Promise => { + let response: unknown; + try { + response = await fetchResult; + } catch { + if (finished) return; + completeTransport(); + failLive('network_error'); + return; + } + if (finished) return; + let ok: unknown; + let json: unknown; + try { + ok = Reflect.get(response as object, 'ok'); + json = Reflect.get(response as object, 'json'); + } catch { + completeTransport(); + failLive('invalid_response'); + return; + } + if (ok !== true) { + completeTransport(); + failLive('http_error'); + return; + } + if (typeof json !== 'function') { + completeTransport(); + failLive('invalid_response'); + return; + } + let body: unknown; + try { + body = await Reflect.apply(json, response, []); + } catch { + if (finished) return; + completeTransport(); + failLive('invalid_response'); + return; + } + if (finished) return; + let parsed: ParsedAuctionBatchResponse | undefined; + try { + parsed = parseResponse(body, options.cachePolicy); + } catch { + parsed = undefined; + } + completeTransport(); + if (!parsed || !responseMembershipIsExact(parsed, slots)) { + failLive('invalid_response'); + return; + } + applyResponse(parsed); + }; + + fetchPending = true; + try { + deadlineArmed = true; + const handle = scheduler.set(() => { + if (finished || !fetchPending) return; + abortFetch(); + clearDeadline(); + failLive('auction_timeout'); + }, input.timeoutMs); + if (deadlineArmed && !finished) deadlineHandle = handle; + else { + try { + scheduler.clear(handle); + } catch { + // A synchronously terminal batch cannot regain deadline authority. + } + } + } catch { + deadlineArmed = false; + fetchPending = false; + failLive('internal_error'); + return publicBatch; + } + if (finished) return publicBatch; + + let fetchResult: Promise; + try { + fetchResult = options.fetcher( + endpoint, + frozen({ + body: input.requestBody, + headers: frozen({ 'content-type': 'application/json' }), + method: 'POST', + signal: controller.signal, + }) + ); + } catch { + completeTransport(); + failLive('network_error'); + return publicBatch; + } + void processFetch(fetchResult); + return publicBatch; + }; + + return frozen({ + create, + dispose: (): void => { + if (disposed) return; + disposed = true; + const active = Array.from(batches); + batches.clear(); + for (let index = 0; index < active.length; index += 1) { + active[index]?.cancel('navigation_disposed'); + } + activeByNavigation.clear(); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts new file mode 100644 index 000000000..189231439 --- /dev/null +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -0,0 +1,566 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { parseTrustedServerAuctionResponseV1 } from '../../src/core/auction'; +import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { + createRuntimeSession, + type NavigationSession, + type RenderAttemptScope, +} from '../../src/kernel/sessions'; +import { + createAuctionBatchService, + type AuctionBatchFetcher, + type AuctionBatchServiceOptions, +} from '../../src/services/auction_batch'; +import type { + RenderAttempt, + RenderCancellationReason, + RenderFailureReason, + RenderOutcome, +} from '../../src/services/render'; + +function navigation(): NavigationSession { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const result = runtime.startInitialNavigation(); + if (!result.ok) throw new Error(result.reason); + return result.value; +} + +interface AttemptHarness { + readonly attempt: RenderAttempt; + readonly outcomes: readonly RenderOutcome[]; +} + +function attemptHarness(owner: RenderAttemptScope): AttemptHarness { + const outcomes: RenderOutcome[] = []; + const observers: Array<(outcome: RenderOutcome) => void> = []; + let outcome: RenderOutcome | undefined; + const settle = (next: RenderOutcome): boolean => { + if (outcome) return false; + outcome = Object.freeze(next); + outcomes.push(outcome); + owner.dispose(); + observers.splice(0).forEach((observer) => observer(outcome!)); + return true; + }; + owner.onDispose('test-render-lifecycle', () => { + if (!outcome) settle({ outcome: 'cancelled', reason: 'navigation_disposed' }); + }); + const attempt = { + id: owner.id, + slot: owner.slot, + generation: owner.generation, + navigationGeneration: owner.navigationGeneration, + parentAttemptId: undefined, + renderSource: undefined, + winnerContext: undefined, + admitDirectWinner: vi.fn(() => true), + admitClaimedWinner: vi.fn(() => false), + beginGamClaim: vi.fn(() => false), + ownerClaimed: vi.fn(() => false), + ownerRegistered: vi.fn(() => false), + beginDirect: vi.fn(() => false), + beginApsDocument: vi.fn(() => false), + beginAdm: vi.fn(() => false), + apsDocumentAccepted: vi.fn(() => false), + accept: () => settle({ outcome: 'accepted' }), + noBid: () => settle({ outcome: 'no_bid' }), + fail: (reason: RenderFailureReason) => settle({ outcome: 'failed', reason }), + cancel: (reason: RenderCancellationReason) => settle({ outcome: 'cancelled', reason }), + onSettled: (observer: (terminal: RenderOutcome) => void) => { + if (outcome) observer(outcome); + else observers.push(observer); + return true; + }, + snapshot: () => ({ + history: Object.freeze(outcome ? ['created', outcome.outcome] : ['created']), + outcome, + state: outcome?.outcome ?? ('created' as const), + }), + } as RenderAttempt; + return { attempt, outcomes }; +} + +function candidateId(index: number): string { + return index.toString(36).padStart(12, 'A'); +} + +function reservationId(index: number): string { + return `r1_${index.toString(36).padStart(22, 'A')}`; +} + +type Decision = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: 'provider_timeout' }; + +function response(decisions: readonly Decision[]): unknown { + const winners = decisions.filter( + (decision): decision is Extract => + decision.outcome === 'winner' + ); + return { + id: 'auction-1', + cur: 'USD', + seatbid: + winners.length === 0 + ? [] + : [ + { + seat: 'prebid', + bid: winners.map((winner, index) => { + const source = { + type: 'adm', + version: 1, + adm: `
${winner.slot}
`, + width: 300, + height: 250, + }; + return { + id: reservationId(index), + impid: winner.slot, + price: index + 1, + adm: source.adm, + w: source.width, + h: source.height, + ext: { + trusted_server: { + candidate_id: winner.candidateId, + slot_id: winner.slot, + render_source: source, + }, + }, + }; + }), + }, + ], + ext: { + trusted_server: { + slot_results: { version: 1, auctionId: 'auction-1', results: decisions }, + }, + }, + }; +} + +function successfulFetcher(body: unknown): AuctionBatchFetcher { + return vi.fn(async () => ({ ok: true, json: async () => body })); +} + +function createService(options: Omit) { + return createAuctionBatchService({ + ...options, + parseResponse: parseTrustedServerAuctionResponseV1, + }); +} + +function abortablePendingFetcher(): { + readonly fetcher: AuctionBatchFetcher; + readonly signals: AbortSignal[]; +} { + const signals: AbortSignal[] = []; + const fetcher: AuctionBatchFetcher = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + const signal = init.signal; + if (!signal) throw new Error('Expected a fetch signal'); + signals.push(signal); + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }); + }) + ); + return { fetcher, signals }; +} + +describe('auction batch service', () => { + it('uses one fetch and applies reversed decisions in immutable request order', async () => { + const attempts = new Map(); + const fetcher = successfulFetcher( + response([ + { slot: 'slot-a', outcome: 'no_bid' }, + { slot: 'slot-b', outcome: 'winner', candidateId: candidateId(0) }, + ]) + ); + const service = createService({ + createAttempt: (owner) => { + const harness = attemptHarness(owner); + attempts.set(owner.slot, harness); + return { ok: true, value: harness.attempt }; + }, + fetcher, + renderWinner: (attempt) => attempt.accept(), + }); + + const batch = service.create({ + navigation: navigation(), + requestBody: '{"adUnits":[]}', + slots: Object.freeze(['slot-b', 'slot-a']), + timeoutMs: 10_000, + }); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-b', path: 'primary', outcome: 'accepted' }, + { slot: 'slot-a', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledWith( + '/auction', + expect.objectContaining({ + method: 'POST', + body: '{"adUnits":[]}', + signal: expect.any(AbortSignal), + }) + ); + expect(attempts.get('slot-b')?.attempt.admitDirectWinner).toHaveBeenCalledOnce(); + expect(Object.isFrozen(await batch.result)).toBe(true); + expect(Object.isFrozen((await batch.result).slots)).toBe(true); + }); + + it('fails only live children on the shared response deadline and aborts the fetch', async () => { + vi.useFakeTimers(); + try { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 100, + }); + + await vi.advanceTimersByTimeAsync(99); + expect(pending.signals[0]?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }, + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }, + ], + }); + expect(pending.signals[0]?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('supersedes only overlapping children and retains the old fetch until all old children settle', async () => { + const firstFetch = abortablePendingFetcher(); + const secondFetch = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const fetchers = [firstFetch.fetcher, secondFetch] as const; + let fetchIndex = 0; + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: (input, init) => fetchers[fetchIndex++]!(input, init), + renderWinner: () => false, + }); + const firstAbort = new AbortController(); + const owner = navigation(); + const first = service.create({ + navigation: owner, + requestBody: '{}', + signal: firstAbort.signal, + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }); + const second = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + expect(firstFetch.signals[0]?.aborted).toBe(false); + await expect(second.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'no_bid' }], + }); + firstAbort.abort(); + await expect(first.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'superseded' }, + { slot: 'slot-b', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }, + ], + }); + expect(firstFetch.signals[0]?.aborted).toBe(true); + }); + + it.each([ + { + name: 'network rejection', + fetcher: vi.fn(async () => Promise.reject(new Error('offline'))), + reason: 'network_error', + }, + { + name: 'non-success response', + fetcher: vi.fn(async () => ({ ok: false, json: async () => ({}) })), + reason: 'http_error', + }, + { + name: 'invalid JSON body', + fetcher: vi.fn(async () => ({ + ok: true, + json: async () => Promise.reject(new SyntaxError('invalid JSON')), + })), + reason: 'invalid_response', + }, + { + name: 'missing slot decision', + fetcher: successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])), + reason: 'invalid_response', + }, + { + name: 'extra slot decision', + fetcher: successfulFetcher( + response([ + { slot: 'slot-a', outcome: 'no_bid' }, + { slot: 'slot-b', outcome: 'no_bid' }, + { slot: 'slot-extra', outcome: 'no_bid' }, + ]) + ), + reason: 'invalid_response', + }, + ] as const)('preserves $name as $reason for every live child', async ({ fetcher, reason }) => { + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher, + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'failed', reason }, + { slot: 'slot-b', path: 'primary', outcome: 'failed', reason }, + ], + }); + }); + + it('passes through an exact server failure without inferring no-bid', async () => { + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: successfulFetcher( + response([{ slot: 'slot-a', outcome: 'failed', reason: 'provider_timeout' }]) + ), + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [ + { + slot: 'slot-a', + path: 'primary', + outcome: 'failed', + reason: 'provider_timeout', + }, + ], + }); + }); + + it('ends the shared deadline after parse while retaining caller cancellation during render', async () => { + vi.useFakeTimers(); + try { + let fetchSignal: AbortSignal | undefined; + const settled = vi.fn(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: vi.fn(async (_input, init) => { + fetchSignal = init.signal; + return { + ok: true, + json: async () => + response([{ slot: 'slot-a', outcome: 'winner', candidateId: candidateId(0) }]), + }; + }), + renderWinner: () => true, + }); + const caller = new AbortController(); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }); + void batch.result.then(settled); + + await vi.advanceTimersByTimeAsync(1_000); + expect(settled).not.toHaveBeenCalled(); + expect(fetchSignal?.aborted).toBe(false); + + caller.abort(); + await expect(batch.result).resolves.toEqual({ + slots: [ + { slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }, + ], + }); + expect(fetchSignal?.aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels every child and the shared fetch when navigation disposes', async () => { + const pending = abortablePendingFetcher(); + const owner = navigation(); + const service = createService({ + createAttempt: (attemptOwner) => ({ + ok: true, + value: attemptHarness(attemptOwner).attempt, + }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a', 'slot-b']), + timeoutMs: 10_000, + }); + + owner.dispose(); + + await expect(batch.result).resolves.toEqual({ + slots: [ + { + slot: 'slot-a', + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }, + { + slot: 'slot-b', + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }, + ], + }); + expect(pending.signals[0]?.aborted).toBe(true); + }); + + it('aborts the old shared fetch when its only child is superseded', async () => { + const firstFetch = abortablePendingFetcher(); + const owner = navigation(); + const fetchers = [ + firstFetch.fetcher, + successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])), + ] as const; + let fetchIndex = 0; + const service = createService({ + createAttempt: (attemptOwner) => ({ + ok: true, + value: attemptHarness(attemptOwner).attempt, + }), + fetcher: (input, init) => fetchers[fetchIndex++]!(input, init), + renderWinner: () => false, + }); + const first = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + const second = service.create({ + navigation: owner, + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + await expect(first.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'superseded' }], + }); + expect(firstFetch.signals[0]?.aborted).toBe(true); + await expect(second.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'no_bid' }], + }); + }); + + it('fails closed without fetching when deadline setup settles reentrantly', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const clear = vi.fn(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher, + renderWinner: () => false, + scheduler: { + clear, + set: (callback) => { + callback(); + return Object.freeze({ handle: true }); + }, + }, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'auction_timeout' }], + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(clear).toHaveBeenCalled(); + }); + + it('settles and skips transport when an attempt refuses settlement observation', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const service = createService({ + createAttempt: (owner) => { + const attempt = attemptHarness(owner).attempt; + return { + ok: true, + value: { + ...attempt, + fail: vi.fn(() => false), + onSettled: vi.fn(() => false), + } as RenderAttempt, + }; + }, + fetcher, + renderWinner: () => false, + }); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 100, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'failed', reason: 'internal_error' }], + }); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); From 9d47aa354a3184bfdaad456f3fb5d0137346fe8c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:42:10 -0700 Subject: [PATCH 306/494] Validate the hard-cutover request API --- .../lib/src/core/registry.ts | 456 +++++++++++++++++- .../trusted-server-js/lib/src/core/request.ts | 166 ++++++- .../trusted-server-js/lib/src/core/types.ts | 84 ++++ .../lib/src/kernel/fallback.ts | 372 +------------- .../lib/test/core/registry.test.ts | 137 ++++++ .../lib/test/core/request.test.ts | 76 +++ 6 files changed, 910 insertions(+), 381 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 06401a5e6..f2979166b 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -1,31 +1,461 @@ -// In-memory registry for ad units registered via tsjs (used by core + extensions). -import type { AdUnit, Size } from './types'; -import { toArray } from './util'; +// Programmatic ad-unit validation plus the legacy registry retained until Task 19. +import type { AdUnit, AddAdUnitsResult, ProgrammaticAdUnit, Size } from './types'; +import { validBoundedString } from './contracts/auction_projection'; import { log } from './log'; +import { toArray } from './util'; + +const MAX_AUCTION_BODY_BYTES = 256 * 1024; +const MAX_PROGRAMMATIC_UNITS = 256; +const MAX_ACTIVE_SLOT_RECORDS = 256; +const MAX_JSON_STRUCTURE_ENTRIES = Math.floor((MAX_AUCTION_BODY_BYTES - 1) / 2); +const textEncoder = new TextEncoder(); + +export type AdUnitRegistrationErrorCode = + | 'invalid_units' + | 'invalid_unit' + | 'invalid_code' + | 'duplicate_code' + | 'slot_collision' + | 'invalid_media_types' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'invalid_bids' + | 'invalid_bidder' + | 'invalid_params' + | 'request_body_too_large' + | 'registry_capacity'; + +export class AdUnitRegistrationError extends Error { + public readonly code: AdUnitRegistrationErrorCode; + public readonly unitIndex?: number; + + public constructor(code: AdUnitRegistrationErrorCode, unitIndex?: number) { + super(code); + this.name = 'AdUnitRegistrationError'; + this.code = code; + if (unitIndex !== undefined) this.unitIndex = unitIndex; + } +} + +interface JsonContainerSnapshot { + readonly array: boolean; + readonly entries: readonly Readonly<{ key: string; value: unknown }>[]; +} + +interface JsonCloneFrame { + readonly output: Record | unknown[]; + readonly snapshot: JsonContainerSnapshot; + readonly source: object; + index: number; +} + +interface JsonMeasureFrame { + readonly array: boolean; + readonly entries: readonly Readonly<{ key: string; value: unknown }>[]; + readonly source: object; + bytes: number; + index: number; +} + +function ownDataRecord(value: unknown): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const output: Record = Object.create(null) as Record; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value: descriptor.value, + writable: true, + }); + } + return output; + } catch { + return undefined; + } +} + +function ownDataArray(value: unknown, maximum: number): readonly unknown[] | undefined { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + length.value > maximum || + Object.getOwnPropertyNames(value).length !== length.value + 1 + ) { + return undefined; + } + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[index] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function exactKeys(record: Record, keys: readonly string[]): boolean { + const actual = Object.keys(record); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function jsonPrimitive(value: unknown): null | boolean | number | string | undefined { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined { + const array = Array.isArray(value); + const values = array ? ownDataArray(value, MAX_JSON_STRUCTURE_ENTRIES) : undefined; + if (array && !values) return undefined; + const record = array ? undefined : ownDataRecord(value); + if (!array && !record) return undefined; + const entries = array + ? values!.map((entry, index) => Object.freeze({ key: String(index), value: entry })) + : Object.keys(record!).map((key) => Object.freeze({ key, value: record![key] })); + return Object.freeze({ array, entries: Object.freeze(entries) }); +} + +/** Copy JSON data without invoking accessors or retaining publisher-owned objects. */ +function copyJsonRecord(value: unknown): Readonly> | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const rootSnapshot = snapshotJsonContainer(value); + if (!rootSnapshot || rootSnapshot.array) return undefined; + const root: Record = {}; + const active = new Set([value]); + const completed = new WeakMap | unknown[]>(); + const stack: JsonCloneFrame[] = [ + { index: 0, output: root, snapshot: rootSnapshot, source: value }, + ]; + let structureEntries = 1; + try { + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.snapshot.entries.length) { + Object.freeze(frame.output); + completed.set(frame.source, frame.output); + active.delete(frame.source); + stack.pop(); + continue; + } + const entry = frame.snapshot.entries[frame.index]; + frame.index += 1; + if (!entry || ++structureEntries > MAX_JSON_STRUCTURE_ENTRIES) return undefined; + const primitive = jsonPrimitive(entry.value); + if (primitive !== undefined || entry.value === null) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: primitive, + writable: true, + }); + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const completedChild = completed.get(entry.value); + if (completedChild) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: completedChild, + writable: true, + }); + continue; + } + const childSnapshot = snapshotJsonContainer(entry.value); + if (!childSnapshot) return undefined; + const child: Record | unknown[] = childSnapshot.array ? [] : {}; + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: child, + writable: true, + }); + active.add(entry.value); + stack.push({ index: 0, output: child, snapshot: childSnapshot, source: entry.value }); + } + return Object.freeze(root); + } catch { + return undefined; + } +} + +function encodedJsonStringBytes(value: string): number { + let bytes = 2; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) bytes += 2; + else if (code <= 0x1f) { + bytes += + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6; + } else if (code <= 0x7f) bytes += 1; + else if (code <= 0x7ff) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else bytes += 6; + } else if (code >= 0xdc00 && code <= 0xdfff) bytes += 6; + else bytes += 3; + if (bytes > MAX_AUCTION_BODY_BYTES) return bytes; + } + return bytes; +} + +function primitiveJsonBytes(value: unknown): number | undefined { + if (value === null) return 4; + if (typeof value === 'boolean') return value ? 4 : 5; + if (typeof value === 'string') return encodedJsonStringBytes(value); + if (typeof value === 'number' && Number.isFinite(value)) return String(value).length; + return undefined; +} + +function boundedBytes(left: number, right: number): number { + return left > MAX_AUCTION_BODY_BYTES - right ? MAX_AUCTION_BODY_BYTES + 1 : left + right; +} + +/** Exact JSON byte measurement that never consults `toJSON` or publisher prototypes. */ +function measureJsonBytes(value: unknown): number | undefined { + const primitive = primitiveJsonBytes(value); + if (primitive !== undefined) return primitive; + if (typeof value !== 'object' || value === null) return undefined; + const root = snapshotJsonContainer(value); + if (!root) return undefined; + const memo = new WeakMap(); + const active = new Set([value]); + const stack: JsonMeasureFrame[] = [ + { array: root.array, bytes: 2, entries: root.entries, index: 0, source: value }, + ]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.entries.length) { + memo.set(frame.source, frame.bytes); + active.delete(frame.source); + stack.pop(); + const parent = stack[stack.length - 1]; + if (!parent) return frame.bytes; + parent.bytes = boundedBytes(parent.bytes, frame.bytes); + if (parent.bytes > MAX_AUCTION_BODY_BYTES) return parent.bytes; + continue; + } + const entry = frame.entries[frame.index]; + const entryIndex = frame.index; + frame.index += 1; + if (!entry) return undefined; + const prefix = + (entryIndex === 0 ? 0 : 1) + (frame.array ? 0 : encodedJsonStringBytes(entry.key) + 1); + frame.bytes = boundedBytes(frame.bytes, prefix); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + const childPrimitive = primitiveJsonBytes(entry.value); + if (childPrimitive !== undefined) { + frame.bytes = boundedBytes(frame.bytes, childPrimitive); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const completed = memo.get(entry.value); + if (completed !== undefined) { + frame.bytes = boundedBytes(frame.bytes, completed); + if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + continue; + } + const child = snapshotJsonContainer(entry.value); + if (!child) return undefined; + active.add(entry.value); + stack.push({ + array: child.array, + bytes: 2, + entries: child.entries, + index: 0, + source: entry.value, + }); + } + return undefined; +} + +function snapshotKnownSlots(knownSlots: ReadonlySet): ReadonlySet { + try { + return new Set(knownSlots); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } +} + +/** + * Validate and detach one complete public registration call before slot mutation. + * + * The returned graph is recursively frozen and safe to serialize later without + * reading publisher accessors again. + */ +export function prepareProgrammaticAdUnits( + value: unknown, + knownSlots: ReadonlySet +): readonly ProgrammaticAdUnit[] { + let units: readonly unknown[] | undefined; + try { + units = Array.isArray(value) ? ownDataArray(value, MAX_PROGRAMMATIC_UNITS) : [value]; + } catch { + units = undefined; + } + if (!units || units.length === 0 || units.length > MAX_PROGRAMMATIC_UNITS) { + throw new AdUnitRegistrationError('invalid_units'); + } + + const occupied = snapshotKnownSlots(knownSlots); + const seen = new Set(); + const prepared: ProgrammaticAdUnit[] = []; + for (let index = 0; index < units.length; index += 1) { + const unit = ownDataRecord(units[index]); + if ( + !unit || + (!exactKeys(unit, ['code', 'mediaTypes']) && !exactKeys(unit, ['code', 'mediaTypes', 'bids'])) + ) { + throw new AdUnitRegistrationError('invalid_unit', index); + } + if (!validBoundedString(unit.code, 256)) { + throw new AdUnitRegistrationError('invalid_code', index); + } + if (seen.has(unit.code)) throw new AdUnitRegistrationError('duplicate_code', index); + if (occupied.has(unit.code)) throw new AdUnitRegistrationError('slot_collision', index); + seen.add(unit.code); + + const mediaTypes = ownDataRecord(unit.mediaTypes); + const banner = ownDataRecord(mediaTypes?.banner); + if ( + !mediaTypes || + !exactKeys(mediaTypes, ['banner']) || + !banner || + !exactKeys(banner, ['sizes']) + ) { + throw new AdUnitRegistrationError('invalid_media_types', index); + } + const rawSizes = ownDataArray(banner.sizes, MAX_JSON_STRUCTURE_ENTRIES); + if (!rawSizes || rawSizes.length === 0) { + throw new AdUnitRegistrationError('invalid_media_types', index); + } + const sizes: Array = []; + for (const rawSize of rawSizes) { + const dimensions = ownDataArray(rawSize, 2); + if ( + !dimensions || + dimensions.length !== 2 || + dimensions.some( + (dimension) => + typeof dimension !== 'number' || + !Number.isFinite(dimension) || + !Number.isInteger(dimension) || + dimension <= 0 + ) + ) { + throw new AdUnitRegistrationError('invalid_dimensions', index); + } + if (dimensions.some((dimension) => (dimension as number) > 4_096)) { + throw new AdUnitRegistrationError('dimensions_out_of_range', index); + } + sizes.push(Object.freeze([dimensions[0] as number, dimensions[1] as number])); + } + + let bids: ProgrammaticAdUnit['bids']; + if (unit.bids !== undefined) { + const rawBids = ownDataArray(unit.bids, MAX_JSON_STRUCTURE_ENTRIES); + if (!rawBids) throw new AdUnitRegistrationError('invalid_bids', index); + const copiedBids: Array[number]> = []; + for (const rawBid of rawBids) { + const bid = ownDataRecord(rawBid); + if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { + throw new AdUnitRegistrationError('invalid_bids', index); + } + if ( + typeof bid.bidder !== 'string' || + bid.bidder.length === 0 || + textEncoder.encode(bid.bidder).byteLength > 64 + ) { + throw new AdUnitRegistrationError('invalid_bidder', index); + } + let params: Readonly> | undefined; + if (bid.params !== undefined) { + params = copyJsonRecord(bid.params); + if (!params) throw new AdUnitRegistrationError('invalid_params', index); + } + copiedBids.push( + Object.freeze({ bidder: bid.bidder, ...(params === undefined ? {} : { params }) }) + ); + } + bids = Object.freeze(copiedBids); + } + + prepared.push( + Object.freeze({ + code: unit.code, + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze(sizes) }), + }), + ...(bids === undefined ? {} : { bids }), + }) + ); + } + + const unitsBytes = measureJsonBytes(prepared); + if (unitsBytes === undefined) throw new AdUnitRegistrationError('invalid_params'); + // `{"adUnits":` + encoded array + `}`. + if (boundedBytes(12, unitsBytes) > MAX_AUCTION_BODY_BYTES) { + throw new AdUnitRegistrationError('request_body_too_large'); + } + if (occupied.size + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { + throw new AdUnitRegistrationError('registry_capacity'); + } + return Object.freeze(prepared); +} + +export function addAdUnitsResult(units: readonly ProgrammaticAdUnit[]): AddAdUnitsResult { + return Object.freeze({ registered: Object.freeze(units.map(({ code }) => code)) }); +} -const registry = new Map(); +// The mutable merge registry remains connected only to the pre-cutover core entry. +const legacyRegistry = new Map(); -// Merge ad unit definitions into the in-memory registry (supports array or single unit). export function addAdUnits(units: AdUnit | AdUnit[]): void { - for (const u of toArray(units)) { - if (!u || !u.code) continue; - registry.set(u.code, { ...registry.get(u.code), ...u }); + for (const unit of toArray(units)) { + if (!unit?.code) continue; + legacyRegistry.set(unit.code, { ...legacyRegistry.get(unit.code), ...unit }); } log.info('addAdUnits:', { count: toArray(units).length }); } -// Convenience helper to grab the first banner size off an ad unit. export function firstSize(unit: AdUnit): Size | null { const sizes = unit.mediaTypes?.banner?.sizes; return sizes && sizes.length ? sizes[0]! : null; } -// Return a snapshot array of all registered ad units. export function getAllUnits(): AdUnit[] { - return Array.from(registry.values()); + return Array.from(legacyRegistry.values()); } -// Look up a unit by its code. export function getUnit(code: string): AdUnit | undefined { - return registry.get(code); + return legacyRegistry.get(code); } diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index 6c41ea498..d0ba05ab8 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -8,8 +8,166 @@ import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace'; +const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; +const REQUEST_ADS_MAX_SLOTS = 256; +const abortSignalAbortedGetter = + typeof AbortSignal === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + +export type RequestAdsInputErrorCode = + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal'; + +export class RequestAdsInputError extends Error { + public readonly code: RequestAdsInputErrorCode; + + public constructor(code: RequestAdsInputErrorCode) { + super(code); + this.name = 'RequestAdsInputError'; + this.code = code; + } +} + +export interface ValidatedRequestAdsOptions { + readonly aborted: boolean; + readonly signal: AbortSignal | undefined; + readonly slots: readonly string[] | undefined; + readonly timeoutMs: number; +} + +function ownDataOptions(value: unknown): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const output: Record = Object.create(null) as Record; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[key] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function ownDataSlots(value: unknown): readonly unknown[] | undefined { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + length.value > REQUEST_ADS_MAX_SLOTS || + Object.getOwnPropertyNames(value).length !== length.value + 1 + ) { + return undefined; + } + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[index] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function readAbortSignal(signal: unknown): boolean | undefined { + try { + return typeof abortSignalAbortedGetter === 'function' + ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) + : undefined; + } catch { + return undefined; + } +} + +/** Validate and detach the complete public request before creating attempts. */ +export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOptions { + if (value === undefined) { + return Object.freeze({ + aborted: false, + signal: undefined, + slots: undefined, + timeoutMs: REQUEST_ADS_DEFAULT_TIMEOUT_MS, + }); + } + const options = ownDataOptions(value); + if ( + !options || + !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') + ) { + throw new RequestAdsInputError('invalid_options'); + } + + let slots: readonly string[] | undefined; + if (Object.prototype.hasOwnProperty.call(options, 'slots')) { + const rawSlots = ownDataSlots(options.slots); + if (!rawSlots) throw new RequestAdsInputError('invalid_slots'); + if (rawSlots.length === 0) throw new RequestAdsInputError('empty_slots'); + const seen = new Set(); + const copy: string[] = []; + for (const slot of rawSlots) { + if ( + typeof slot !== 'string' || + slot.length === 0 || + new TextEncoder().encode(slot).byteLength > 256 || + /[\p{Cc}]/u.test(slot) || + /[\uD800-\uDFFF]/u.test(slot) + ) { + throw new RequestAdsInputError('invalid_slots'); + } + if (seen.has(slot)) throw new RequestAdsInputError('duplicate_slot'); + seen.add(slot); + copy.push(slot); + } + slots = Object.freeze(copy); + } + + let timeoutMs = REQUEST_ADS_DEFAULT_TIMEOUT_MS; + if (Object.prototype.hasOwnProperty.call(options, 'timeoutMs')) { + if ( + typeof options.timeoutMs !== 'number' || + !Number.isInteger(options.timeoutMs) || + options.timeoutMs < 100 || + options.timeoutMs > 30_000 + ) { + throw new RequestAdsInputError('invalid_timeout'); + } + timeoutMs = options.timeoutMs; + } + + let signal: AbortSignal | undefined; + let aborted = false; + if (Object.prototype.hasOwnProperty.call(options, 'signal')) { + const observed = readAbortSignal(options.signal); + if (observed === undefined) throw new RequestAdsInputError('invalid_signal'); + signal = options.signal as AbortSignal; + aborted = observed; + } + return Object.freeze({ aborted, signal, slots, timeoutMs }); +} + export type RequestAdsCallback = () => void; -export interface RequestAdsOptions { +export interface LegacyRequestAdsOptions { bidsBackHandler?: RequestAdsCallback | undefined; timeout?: number | undefined; } @@ -29,14 +187,14 @@ type RenderCreativeInlineOptions = { // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( - callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, - _maybeOpts?: RequestAdsOptions + callbackOrOpts?: RequestAdsCallback | LegacyRequestAdsOptions, + _maybeOpts?: LegacyRequestAdsOptions ): void { let callback: RequestAdsCallback | undefined; if (typeof callbackOrOpts === 'function') { callback = callbackOrOpts as RequestAdsCallback; } else { - callback = (callbackOrOpts as RequestAdsOptions | undefined)?.bidsBackHandler; + callback = (callbackOrOpts as LegacyRequestAdsOptions | undefined)?.bidsBackHandler; } log.info('requestAds: called', { hasCallback: typeof callback === 'function' }); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index f10300e18..9335dda00 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -330,6 +330,90 @@ export interface BootManifestV1 { readonly integrations: readonly BootManifestIntegrationV1[]; } +/** One direct-auction ad unit admitted into the current navigation. */ +export interface ProgrammaticAdUnit { + readonly code: string; + readonly mediaTypes: Readonly<{ + banner: Readonly<{ sizes: readonly (readonly [number, number])[] }>; + }>; + readonly bids?: readonly Readonly<{ + bidder: string; + params?: Readonly>; + }>[]; +} + +export interface AddAdUnitsResult { + readonly registered: readonly string[]; +} + +export interface RequestAdsOptions { + readonly slots?: readonly string[]; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; +} + +export type RenderFailureReason = + | 'auction_timeout' + | AuctionSlotFailureReason + | 'network_error' + | 'http_error' + | 'invalid_response' + | 'slot_unresolved' + | 'descriptor_invalid' + | 'invalid_dimensions' + | 'dimensions_out_of_range' + | 'no_render_source' + | 'registry_full' + | 'capability_registry_full' + | 'external_queue_full' + | 'external_ready_timeout' + | 'external_artifact_incompatible' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'prebid_selection_timeout' + | 'reservation_collision' + | 'identity_generation_failed' + | 'cycle_unattributable' + | 'slot_quarantined' + | 'gpt_request_failed' + | 'gpt_request_timeout' + | 'gpt_completion_timeout' + | 'reconciliation_capacity' + | 'gam_empty' + | 'bridge_claim_timeout' + | 'bridge_id_mismatch' + | 'owner_registration_timeout' + | 'owner_insertion_timeout' + | 'renderer_document_no_load' + | 'runner_no_load' + | 'runner_failed' + | 'cache_network_error' + | 'cache_http_error' + | 'cache_invalid_response' + | 'adm_document_no_load' + | 'abi_mismatch' + | 'bundle_partial'; + +export type RequestAdsSlotResult = + | Readonly<{ slot: string; path: 'primary' | 'fallback'; outcome: 'accepted' }> + | Readonly<{ slot: string; path: 'primary' | 'fallback'; outcome: 'no_bid' }> + | Readonly<{ + slot: string; + path: 'primary' | 'fallback'; + outcome: 'failed'; + reason: RenderFailureReason; + }> + | Readonly<{ + slot: string; + path: 'primary' | 'fallback'; + outcome: 'cancelled'; + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed'; + }>; + +export interface RequestAdsResult { + readonly slots: readonly RequestAdsSlotResult[]; +} + export interface TsjsApi { version: string; que: Array<() => void>; diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index b61d44b39..7276ba00d 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -1,65 +1,21 @@ import { parseCacheFetchPolicyV1 } from '../core/config'; -import { - parseBrowserAuctionProjectionV1, - validBoundedString, -} from '../core/contracts/auction_projection'; +import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; import { log } from '../core/log'; +import { prepareProgrammaticAdUnits } from '../core/registry'; +import { validateRequestAdsOptions } from '../core/request'; import type { BootManifestV1 } from '../core/types'; +export { AdUnitRegistrationError, type AdUnitRegistrationErrorCode } from '../core/registry'; +export { RequestAdsInputError, type RequestAdsInputErrorCode } from '../core/request'; + import type { BootFailureReason } from './integration_registry'; -const textEncoder = new TextEncoder(); -const MAX_AUCTION_BODY_BYTES = 256 * 1024; -const MAX_JSON_ARRAY_ITEMS = Math.floor((MAX_AUCTION_BODY_BYTES - 1) / 2); const SAFE_PROJECTION = { version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, bids: [], } as const; -export class RequestAdsInputError extends Error { - public readonly code: - | 'invalid_options' - | 'invalid_slots' - | 'empty_slots' - | 'duplicate_slot' - | 'invalid_timeout' - | 'invalid_signal'; - - public constructor(code: RequestAdsInputError['code']) { - super(code); - this.name = 'RequestAdsInputError'; - this.code = code; - } -} - -export type AdUnitRegistrationErrorCode = - | 'invalid_units' - | 'invalid_unit' - | 'invalid_code' - | 'duplicate_code' - | 'slot_collision' - | 'invalid_media_types' - | 'invalid_dimensions' - | 'dimensions_out_of_range' - | 'invalid_bids' - | 'invalid_bidder' - | 'invalid_params' - | 'request_body_too_large' - | 'registry_capacity'; - -export class AdUnitRegistrationError extends Error { - public readonly code: AdUnitRegistrationErrorCode; - public readonly unitIndex?: number; - - public constructor(code: AdUnitRegistrationErrorCode, unitIndex?: number) { - super(code); - this.name = 'AdUnitRegistrationError'; - this.code = code; - if (unitIndex !== undefined) this.unitIndex = unitIndex; - } -} - export class TsjsUnavailableError extends Error { public readonly code = 'runtime_unavailable' as const; public readonly releaseId: string; @@ -248,318 +204,6 @@ export function buildFallbackBoot(releaseId: string, candidate: unknown): Readon }); } -function validSlotId(value: unknown): value is string { - return validBoundedString(value, 256); -} - -function readAborted(signal: unknown): boolean | undefined { - try { - const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - return getter?.call(signal) as boolean | undefined; - } catch { - return undefined; - } -} - -function validateRequestOptions(value: unknown): { - readonly slots: readonly string[] | undefined; - readonly aborted: boolean; -} { - if (value === undefined) return { slots: undefined, aborted: false }; - const options = ownDataRecord(value); - if ( - !options || - !Object.keys(options).every((key) => ['slots', 'timeoutMs', 'signal'].includes(key)) - ) { - throw new RequestAdsInputError('invalid_options'); - } - let slots: readonly string[] | undefined; - if (Object.prototype.hasOwnProperty.call(options, 'slots')) { - const candidateSlots = snapshotOwnArray(options.slots, 256); - if (!candidateSlots) { - throw new RequestAdsInputError('invalid_slots'); - } - if (candidateSlots.length === 0) throw new RequestAdsInputError('empty_slots'); - const seen = new Set(); - const copy: string[] = []; - for (const slot of candidateSlots) { - if (!validSlotId(slot)) throw new RequestAdsInputError('invalid_slots'); - if (seen.has(slot)) throw new RequestAdsInputError('duplicate_slot'); - seen.add(slot); - copy.push(slot); - } - slots = Object.freeze(copy); - } - if ( - Object.prototype.hasOwnProperty.call(options, 'timeoutMs') && - (!Number.isInteger(options.timeoutMs) || - (options.timeoutMs as number) < 100 || - (options.timeoutMs as number) > 30_000) - ) { - throw new RequestAdsInputError('invalid_timeout'); - } - let aborted = false; - if (Object.prototype.hasOwnProperty.call(options, 'signal')) { - const candidate = readAborted(options.signal); - if (candidate === undefined) throw new RequestAdsInputError('invalid_signal'); - aborted = candidate; - } - return { slots, aborted }; -} - -interface JsonMeasurement { - readonly bytes: number; -} - -interface JsonMeasurementContext { - readonly memo: WeakMap; - readonly snapshots: WeakMap; -} - -interface JsonNode { - readonly entries: readonly JsonEntry[]; -} - -interface JsonEntry { - readonly prefixBytes: number; - readonly value: unknown; -} - -interface JsonFrame { - readonly object: object; - readonly node: JsonNode; - bytes: number; - index: number; -} - -const JSON_TOO_LARGE = Symbol('json_too_large'); -const TOO_LARGE_MEASUREMENT = Object.freeze({ bytes: MAX_AUCTION_BODY_BYTES + 1 }); - -function boundedByteSum(left: number, right: number): number { - return Math.min(MAX_AUCTION_BODY_BYTES + 1, left + right); -} - -function primitiveJsonBytes(value: unknown): number | undefined { - if (value === null) return 4; - if (typeof value === 'boolean') return value ? 4 : 5; - if (typeof value === 'string') return textEncoder.encode(JSON.stringify(value)).length; - if (typeof value === 'number' && Number.isFinite(value)) return String(value).length; - return undefined; -} - -function snapshotJsonNode( - value: unknown, - context: JsonMeasurementContext, - recordSnapshot?: Record -): JsonNode | typeof JSON_TOO_LARGE | undefined { - if (typeof value !== 'object' || value === null) return undefined; - if (context.snapshots.has(value)) { - return context.snapshots.get(value) ?? undefined; - } - let node: JsonNode | typeof JSON_TOO_LARGE | undefined; - try { - let entries: JsonEntry[]; - if (recordSnapshot) { - entries = Object.keys(recordSnapshot).map((key, index) => ({ - prefixBytes: (index === 0 ? 0 : 1) + textEncoder.encode(JSON.stringify(key)).length + 1, - value: recordSnapshot[key], - })); - } else if (Array.isArray(value)) { - if (value.length > MAX_JSON_ARRAY_ITEMS) { - node = JSON_TOO_LARGE; - return node; - } - const values = snapshotOwnArray(value, MAX_JSON_ARRAY_ITEMS); - if (!values) return undefined; - entries = values.map((entry, index) => ({ - prefixBytes: index === 0 ? 0 : 1, - value: entry, - })); - } else { - const record = ownDataRecord(value); - if (!record) return undefined; - entries = Object.keys(record).map((key, index) => ({ - prefixBytes: (index === 0 ? 0 : 1) + textEncoder.encode(JSON.stringify(key)).length + 1, - value: record[key], - })); - } - node = Object.freeze({ entries: Object.freeze(entries) }); - return node; - } catch { - return undefined; - } finally { - context.snapshots.set(value, node ?? null); - } -} - -function measureJsonData( - value: unknown, - context: JsonMeasurementContext, - recordSnapshot?: Record -): JsonMeasurement | undefined { - const primitiveBytes = primitiveJsonBytes(value); - if (primitiveBytes !== undefined) return { bytes: primitiveBytes }; - if (typeof value !== 'object' || value === null) return undefined; - const cached = context.memo.get(value); - if (cached) return cached; - const root = snapshotJsonNode(value, context, recordSnapshot); - if (root === JSON_TOO_LARGE) return TOO_LARGE_MEASUREMENT; - if (!root) return undefined; - - const active = new Set([value]); - const stack: JsonFrame[] = [{ object: value, node: root, bytes: 2, index: 0 }]; - while (stack.length > 0) { - const frame = stack[stack.length - 1]; - if (!frame) return undefined; - if (frame.index >= frame.node.entries.length) { - const measurement = Object.freeze({ bytes: frame.bytes }); - context.memo.set(frame.object, measurement); - active.delete(frame.object); - stack.pop(); - const parent = stack[stack.length - 1]; - if (!parent) return measurement; - parent.bytes = boundedByteSum(parent.bytes, measurement.bytes); - if (parent.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - continue; - } - - const entry = frame.node.entries[frame.index]; - frame.index += 1; - if (!entry) return undefined; - frame.bytes = boundedByteSum(frame.bytes, entry.prefixBytes); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - const childBytes = primitiveJsonBytes(entry.value); - if (childBytes !== undefined) { - frame.bytes = boundedByteSum(frame.bytes, childBytes); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - continue; - } - if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { - return undefined; - } - const childMeasurement = context.memo.get(entry.value); - if (childMeasurement) { - frame.bytes = boundedByteSum(frame.bytes, childMeasurement.bytes); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return TOO_LARGE_MEASUREMENT; - continue; - } - const childNode = snapshotJsonNode(entry.value, context); - if (childNode === JSON_TOO_LARGE) return TOO_LARGE_MEASUREMENT; - if (!childNode) return undefined; - active.add(entry.value); - stack.push({ object: entry.value, node: childNode, bytes: 2, index: 0 }); - } - return undefined; -} - -function measureJsonRecord( - value: unknown, - context: JsonMeasurementContext -): JsonMeasurement | undefined { - try { - if (Array.isArray(value)) return undefined; - } catch { - return undefined; - } - const record = ownDataRecord(value); - return record ? measureJsonData(value, context, record) : undefined; -} - -function validateProgrammaticUnits(value: unknown, knownSlots: ReadonlySet): void { - let units: readonly unknown[] | undefined; - try { - units = Array.isArray(value) ? snapshotOwnArray(value, 256) : [value]; - } catch { - throw new AdUnitRegistrationError('invalid_units'); - } - if (!units) throw new AdUnitRegistrationError('invalid_units'); - if (units.length === 0 || units.length > 256) throw new AdUnitRegistrationError('invalid_units'); - const seen = new Set(); - const measurementContext: JsonMeasurementContext = { - memo: new WeakMap(), - snapshots: new WeakMap(), - }; - for (let index = 0; index < units.length; index += 1) { - const unit = ownDataRecord(units[index]); - if ( - !unit || - (!exactKeys(unit, ['code', 'mediaTypes']) && !exactKeys(unit, ['code', 'mediaTypes', 'bids'])) - ) { - throw new AdUnitRegistrationError('invalid_unit', index); - } - if (!validSlotId(unit.code)) throw new AdUnitRegistrationError('invalid_code', index); - if (seen.has(unit.code)) throw new AdUnitRegistrationError('duplicate_code', index); - if (knownSlots.has(unit.code)) throw new AdUnitRegistrationError('slot_collision', index); - seen.add(unit.code); - const mediaTypes = ownDataRecord(unit.mediaTypes); - const banner = ownDataRecord(mediaTypes?.banner); - if ( - !mediaTypes || - !exactKeys(mediaTypes, ['banner']) || - !banner || - !exactKeys(banner, ['sizes']) - ) { - throw new AdUnitRegistrationError('invalid_media_types', index); - } - const sizes = snapshotOwnArray(banner.sizes, MAX_JSON_ARRAY_ITEMS); - if (!sizes || sizes.length === 0) { - throw new AdUnitRegistrationError('invalid_media_types', index); - } - for (const size of sizes) { - const dimensions = snapshotOwnArray(size, 2); - if ( - !dimensions || - dimensions.length !== 2 || - dimensions.some( - (dimension) => - typeof dimension !== 'number' || - !Number.isFinite(dimension) || - !Number.isInteger(dimension) || - dimension <= 0 - ) - ) { - throw new AdUnitRegistrationError('invalid_dimensions', index); - } - if (dimensions.some((dimension) => (dimension as number) > 4096)) { - throw new AdUnitRegistrationError('dimensions_out_of_range', index); - } - } - if (unit.bids !== undefined) { - const bids = snapshotOwnArray(unit.bids, MAX_JSON_ARRAY_ITEMS); - if (!bids) throw new AdUnitRegistrationError('invalid_bids', index); - for (const rawBid of bids) { - const bid = ownDataRecord(rawBid); - if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { - throw new AdUnitRegistrationError('invalid_bids', index); - } - if ( - typeof bid.bidder !== 'string' || - textEncoder.encode(bid.bidder).length > 64 || - bid.bidder.length === 0 - ) { - throw new AdUnitRegistrationError('invalid_bidder', index); - } - if (bid.params !== undefined) { - const measured = measureJsonRecord(bid.params, measurementContext); - if (!measured) { - throw new AdUnitRegistrationError('invalid_params', index); - } - } - } - } - } - const measured = measureJsonData(units, measurementContext); - if (!measured) { - throw new AdUnitRegistrationError('invalid_params'); - } - if (measured.bytes > MAX_AUCTION_BODY_BYTES) { - throw new AdUnitRegistrationError('request_body_too_large'); - } - if (knownSlots.size + units.length > 256) { - throw new AdUnitRegistrationError('registry_capacity'); - } -} - const LOG_LEVELS = Object.freeze({ silent: true, error: true, @@ -620,14 +264,14 @@ export function createFallbackFields( addAdUnits: { enumerable: true, value: (units: unknown) => { - validateProgrammaticUnits(units, known); + prepareProgrammaticAdUnits(units, known); throw new TsjsUnavailableError(options.releaseId, options.reason); }, }, requestAds: { enumerable: true, value: async (requestOptions?: unknown) => { - const validated = validateRequestOptions(requestOptions); + const validated = validateRequestAdsOptions(requestOptions); const selected = validated.slots ?? knownSlots; return deepFreeze({ slots: selected.map((slot) => diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index 51b0e3a84..cc0fe6283 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -1,6 +1,29 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; +import { AdUnitRegistrationError, prepareProgrammaticAdUnits } from '../../src/core/registry'; + +function unit(code = 'programmatic-slot'): Record { + return { + code, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: { placement: 7 } }], + }; +} + +function expectRegistrationError( + callback: () => unknown, + code: AdUnitRegistrationError['code'], + unitIndex?: number +): void { + try { + callback(); + throw new Error('should reject registration'); + } catch (error) { + expect(error).toBeInstanceOf(AdUnitRegistrationError); + expect(error).toMatchObject({ code, ...(unitIndex === undefined ? {} : { unitIndex }) }); + } +} describe('registry', () => { beforeEach(async () => { @@ -26,4 +49,118 @@ describe('registry', () => { expect(all.length).toBe(1); expect(firstSize(all[0]!)!.join('x')).toBe('320x50'); }); + + it('detaches and recursively freezes one or many exact programmatic units', () => { + const first = unit('first'); + const second = unit('second'); + const prepared = prepareProgrammaticAdUnits([first, second], new Set(['server-slot'])); + + expect(prepared.map(({ code }) => code)).toEqual(['first', 'second']); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared[0])).toBe(true); + expect(Object.isFrozen(prepared[0]?.mediaTypes.banner.sizes)).toBe(true); + expect(Object.isFrozen(prepared[0]?.bids?.[0]?.params)).toBe(true); + ( + (first.bids as Array<{ params: { placement: number } }>)[0]!.params as { placement: number } + ).placement = 99; + expect(prepared[0]?.bids?.[0]?.params).toEqual({ placement: 7 }); + + expect(prepareProgrammaticAdUnits(unit('single'), new Set())).toHaveLength(1); + }); + + it.each([ + [null, 'invalid_unit', 0], + [[], 'invalid_units', undefined], + [Array.from({ length: 257 }, (_, index) => unit(`slot-${index}`)), 'invalid_units', undefined], + [{ ...unit(), unknown: true }, 'invalid_unit', 0], + [{ code: '', mediaTypes: { banner: { sizes: [[300, 250]] } } }, 'invalid_code', 0], + [[unit('same'), unit('same')], 'duplicate_code', 1], + [unit('occupied'), 'slot_collision', 0], + [{ code: 'slot', mediaTypes: {} }, 'invalid_media_types', 0], + [{ code: 'slot', mediaTypes: { banner: { sizes: [] } } }, 'invalid_media_types', 0], + [{ code: 'slot', mediaTypes: { banner: { sizes: [[0, 250]] } } }, 'invalid_dimensions', 0], + [{ code: 'slot', mediaTypes: { banner: { sizes: [[1.5, 250]] } } }, 'invalid_dimensions', 0], + [ + { code: 'slot', mediaTypes: { banner: { sizes: [[4_097, 250]] } } }, + 'dimensions_out_of_range', + 0, + ], + [{ ...unit(), bids: null }, 'invalid_bids', 0], + [{ ...unit(), bids: [{ bidder: '' }] }, 'invalid_bidder', 0], + [{ ...unit(), bids: [{ bidder: 'a'.repeat(65) }] }, 'invalid_bidder', 0], + [{ ...unit(), bids: [{ bidder: 'fictional', params: [] }] }, 'invalid_params', 0], + ] as const)('rejects invalid registration %# with the exact code', (candidate, code, index) => { + const occupied = new Set(candidate === null ? [] : ['occupied']); + expectRegistrationError(() => prepareProgrammaticAdUnits(candidate, occupied), code, index); + }); + + it('rejects accessors, foreign prototypes, cyclic params, and oversized bodies without reads', () => { + const getter = vi.fn(() => 'accessed'); + const accessor = unit(); + Object.defineProperty(accessor, 'code', { enumerable: true, get: getter }); + expectRegistrationError( + () => prepareProgrammaticAdUnits(accessor, new Set()), + 'invalid_unit', + 0 + ); + expect(getter).not.toHaveBeenCalled(); + + const foreign = Object.assign(Object.create({ inherited: true }), unit()); + expectRegistrationError( + () => prepareProgrammaticAdUnits(foreign, new Set()), + 'invalid_unit', + 0 + ); + + const cyclic: Record = {}; + cyclic.self = cyclic; + expectRegistrationError( + () => + prepareProgrammaticAdUnits( + { ...unit(), bids: [{ bidder: 'fictional', params: cyclic }] }, + new Set() + ), + 'invalid_params', + 0 + ); + + expectRegistrationError( + () => + prepareProgrammaticAdUnits( + { + ...unit(), + bids: [{ bidder: 'fictional', params: { payload: 'x'.repeat(256 * 1024) } }], + }, + new Set() + ), + 'request_body_too_large' + ); + }); + + it('accepts exact bidder and dimension boundaries and enforces combined capacity last', () => { + for (const bidderLength of [63, 64]) { + expect( + prepareProgrammaticAdUnits( + { ...unit(), bids: [{ bidder: 'a'.repeat(bidderLength), params: {} }] }, + new Set() + ) + ).toHaveLength(1); + } + for (const dimension of [1, 4_096]) { + expect( + prepareProgrammaticAdUnits( + { + code: `slot-${dimension}`, + mediaTypes: { banner: { sizes: [[dimension, dimension]] } }, + }, + new Set() + ) + ).toHaveLength(1); + } + const existing = new Set(Array.from({ length: 256 }, (_, index) => `server-${index}`)); + expectRegistrationError( + () => prepareProgrammaticAdUnits(unit('overflow'), existing), + 'registry_capacity' + ); + }); }); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 2fc652053..26db64b26 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -2,16 +2,92 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import envelope from '../fixtures/aps-renderer-v1.json'; import type { addAdUnits } from '../../src/core/registry'; +import { RequestAdsInputError, validateRequestAdsOptions } from '../../src/core/request'; /** Test view of the global scope with a mockable `fetch`. */ const testGlobal = globalThis as unknown as { fetch: ReturnType }; type AddAdUnitsArg = Parameters[0]; +function expectInputError(callback: () => unknown, code: RequestAdsInputError['code']): void { + try { + callback(); + throw new Error('should reject request options'); + } catch (error) { + expect(error).toBeInstanceOf(RequestAdsInputError); + expect(error).toMatchObject({ code }); + } +} + async function flushRequestAds(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +describe('requestAds input contract', () => { + it('accepts omitted options and snapshots ordered slots with the exact default timeout', () => { + expect(validateRequestAdsOptions(undefined)).toEqual({ + aborted: false, + signal: undefined, + slots: undefined, + timeoutMs: 10_000, + }); + const slots = ['server-slot', 'programmatic-slot']; + const validated = validateRequestAdsOptions({ slots, timeoutMs: 100 }); + slots.reverse(); + expect(validated).toEqual({ + aborted: false, + signal: undefined, + slots: ['server-slot', 'programmatic-slot'], + timeoutMs: 100, + }); + expect(Object.isFrozen(validated)).toBe(true); + expect(Object.isFrozen(validated.slots)).toBe(true); + }); + + it.each([null, [], new Date(), { unknown: true }])( + 'rejects non-exact options %#', + (candidate) => { + expectInputError(() => validateRequestAdsOptions(candidate), 'invalid_options'); + } + ); + + it('rejects accessors without invoking them', () => { + const getter = vi.fn(() => ['slot']); + const options = {}; + Object.defineProperty(options, 'slots', { enumerable: true, get: getter }); + expectInputError(() => validateRequestAdsOptions(options), 'invalid_options'); + expect(getter).not.toHaveBeenCalled(); + }); + + it.each([ + [{ slots: 'slot' }, 'invalid_slots'], + [{ slots: [] }, 'empty_slots'], + [{ slots: ['slot', 'slot'] }, 'duplicate_slot'], + [{ slots: [''] }, 'invalid_slots'], + [{ slots: ['x'.repeat(257)] }, 'invalid_slots'], + [{ slots: Array.from({ length: 257 }, (_, index) => `slot-${index}`) }, 'invalid_slots'], + [{ timeoutMs: 99 }, 'invalid_timeout'], + [{ timeoutMs: 30_001 }, 'invalid_timeout'], + [{ timeoutMs: 100.5 }, 'invalid_timeout'], + [{ signal: { aborted: false } }, 'invalid_signal'], + ] as const)('rejects request boundary %#', (candidate, code) => { + expectInputError(() => validateRequestAdsOptions(candidate), code); + }); + + it('accepts exact timeout and AbortSignal boundaries through the platform brand getter', () => { + const controller = new AbortController(); + expect( + validateRequestAdsOptions({ timeoutMs: 30_000, signal: controller.signal }) + ).toMatchObject({ aborted: false, signal: controller.signal, timeoutMs: 30_000 }); + controller.abort(); + expect(validateRequestAdsOptions({ timeoutMs: 100, signal: controller.signal })).toMatchObject({ + aborted: true, + signal: controller.signal, + timeoutMs: 100, + }); + }); +}); + describe('request.requestAds', () => { let originalFetch: typeof globalThis.fetch; From c6de718e51115727cb3f71690d9a00ab2ae7324e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:43:05 -0700 Subject: [PATCH 307/494] Harden auction settlement and slot snapshots --- .../lib/src/services/auction_batch.ts | 66 +++++++++++-------- .../lib/src/services/slots.ts | 23 +++++++ .../lib/test/services/auction_batch.test.ts | 31 +++++++++ .../lib/test/services/slots.test.ts | 40 +++++++++++ 4 files changed, 134 insertions(+), 26 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/auction_batch.ts b/crates/trusted-server-js/lib/src/services/auction_batch.ts index 91ce304a9..24dee8c92 100644 --- a/crates/trusted-server-js/lib/src/services/auction_batch.ts +++ b/crates/trusted-server-js/lib/src/services/auction_batch.ts @@ -252,19 +252,43 @@ export function createAuctionBatchService( finishIfComplete(); }; + const containCancellation = (child: BatchChild, reason: RenderCancellationReason): void => { + try { + child.attempt.cancel(reason); + } catch { + // Synthetic public settlement below contains a broken attempt implementation. + } + if (!child.terminal) settleIndex(child.index, cancelledResult(child.slot, reason)); + }; + + const containFailure = (child: BatchChild, reason: RenderFailureReason): void => { + try { + child.attempt.fail(reason); + } catch { + // Synthetic public settlement below contains a broken attempt implementation. + } + if (!child.terminal) settleIndex(child.index, failedResult(child.slot, reason)); + }; + + const containNoBid = (child: BatchChild): void => { + try { + child.attempt.noBid(); + } catch { + // Synthetic public settlement below contains a broken attempt implementation. + } + if (!child.terminal) { + settleIndex( + child.index, + terminalResult(child.slot, frozen({ outcome: 'no_bid' as const })) + ); + } + }; + const cancelLive = (reason: RenderCancellationReason): void => { for (let index = 0; index < children.length; index += 1) { const child = children[index]; if (!child || child.terminal) continue; - let cancelled: boolean; - try { - cancelled = child.attempt.cancel(reason) === true; - } catch { - cancelled = false; - } - if (!cancelled && !child.terminal) { - settleIndex(index, cancelledResult(child.slot, reason)); - } + containCancellation(child, reason); } finishIfComplete(); }; @@ -344,11 +368,7 @@ export function createAuctionBatchService( observing = false; } if (!observing && !child.terminal) { - try { - created.value.fail('internal_error'); - } catch { - settleIndex(index, failedResult(slot, 'internal_error')); - } + containFailure(child, 'internal_error'); } } building = false; @@ -384,13 +404,7 @@ export function createAuctionBatchService( for (let index = 0; index < children.length; index += 1) { const child = children[index]; if (!child || child.terminal) continue; - try { - if (child.attempt.fail(reason) !== true && !child.terminal) { - settleIndex(index, failedResult(child.slot, reason)); - } - } catch { - settleIndex(index, failedResult(child.slot, reason)); - } + containFailure(child, reason); } finishIfComplete(); }; @@ -410,15 +424,15 @@ export function createAuctionBatchService( if (!child || child.terminal) continue; const decision = decisions.get(child.slot); if (!decision) { - child.attempt.fail('invalid_response'); + containFailure(child, 'invalid_response'); continue; } if (decision.outcome === 'no_bid') { - child.attempt.noBid(); + containNoBid(child); continue; } if (decision.outcome === 'failed') { - child.attempt.fail(decision.reason); + containFailure(child, decision.reason); continue; } const bid = bids.get(decision.candidateId); @@ -435,7 +449,7 @@ export function createAuctionBatchService( admitted = false; } if (!admitted || !bid) { - if (!child.terminal) child.attempt.fail('winner_not_renderable'); + if (!child.terminal) containFailure(child, 'winner_not_renderable'); continue; } let rendering: boolean; @@ -444,7 +458,7 @@ export function createAuctionBatchService( } catch { rendering = false; } - if (!rendering && !child.terminal) child.attempt.fail('winner_not_renderable'); + if (!rendering && !child.terminal) containFailure(child, 'winner_not_renderable'); } }; diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 12f9d2296..53fcfde7c 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -32,11 +32,14 @@ export interface SlotRegistration { readonly source: SlotSource; readonly adUnitCode?: string; readonly domAliases?: readonly string[]; + /** Detached programmatic `/auction` unit; absent for server projection slots. */ + readonly directAuctionUnit?: Readonly; } /** Public immutable view of a registered slot. */ export interface SlotRecord { readonly adUnitCode: string | undefined; + readonly directAuctionUnit?: Readonly; readonly domAliases: readonly string[]; readonly navigationGeneration: object; readonly ordinal: number; @@ -138,6 +141,7 @@ export interface SlotService { ) => SlotRegistrationResult; readonly request: (input: SlotRequestInput) => SlotRequestHandle; readonly requestBatch: (inputs: readonly SlotBatchRequestInput[]) => readonly SlotRequestHandle[]; + readonly snapshotRegisteredSlots: (owner: NavigationSession) => readonly SlotRecord[] | undefined; readonly resolveAdUnitCode: (adUnitCode: string) => SlotRecord | undefined; readonly resolveDomAlias: (alias: string) => SlotRecord | undefined; readonly resolveRegisteredSlot: (registeredSlotId: string) => SlotRecord | undefined; @@ -1226,6 +1230,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const prepared: Array<{ readonly adUnitCode: string | undefined; readonly aliases: readonly string[]; + readonly directAuctionUnit: Readonly | undefined; readonly id: string; readonly placementKeys: readonly string[]; readonly source: SlotSource; @@ -1238,6 +1243,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const id = registration.registeredSlotId; const source = registration.source; const adUnitCode = registration.adUnitCode; + const directAuctionUnit = registration.directAuctionUnit; const aliases = frozenAliases(registration.domAliases); if ( typeof id !== 'string' || @@ -1245,6 +1251,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { (source !== 'server' && source !== 'programmatic') || (adUnitCode !== undefined && (typeof adUnitCode !== 'string' || !validSlotIdentity(adUnitCode))) || + (directAuctionUnit !== undefined && + (source !== 'programmatic' || + typeof directAuctionUnit !== 'object' || + directAuctionUnit === null || + !Object.isFrozen(directAuctionUnit))) || aliases === undefined ) { return Object.freeze({ ok: false, reason: 'invalid_slot_id' }); @@ -1260,6 +1271,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { prepared[prepared.length] = { adUnitCode, aliases, + directAuctionUnit, id, placementKeys: registrationPlacementKeys, source, @@ -1285,6 +1297,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const ordinal = state.nextOrdinal + index; const view: SlotRecord = Object.freeze({ adUnitCode: registration.adUnitCode, + ...(registration.directAuctionUnit === undefined + ? {} + : { directAuctionUnit: registration.directAuctionUnit }), domAliases: registration.aliases, navigationGeneration: owner.generation, ordinal, @@ -1972,6 +1987,14 @@ export function createSlotService(options: SlotServiceOptions): SlotService { register, request, requestBatch, + snapshotRegisteredSlots: (owner: NavigationSession): readonly SlotRecord[] | undefined => { + if (!owner.isCurrent()) return undefined; + const state = mapValue(navigationStates, owner.generation); + if (!state || state.disposed || state.owner !== owner) return undefined; + const records = mapValueSnapshot(state.records); + records.sort((left, right) => left.view.ordinal - right.view.ordinal); + return Object.freeze(records.map(({ view }) => view)); + }, resolveAdUnitCode: (adUnitCode: string) => resolveUnique(adUnitCodes, adUnitCode), resolveDomAlias: (alias: string) => resolveUnique(domAliases, alias), resolveRegisteredSlot: (registeredSlotId: string) => diff --git a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts index 189231439..617a10e2d 100644 --- a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -563,4 +563,35 @@ describe('auction batch service', () => { }); expect(fetcher).not.toHaveBeenCalled(); }); + + it('contains an attempt that claims cancellation without notifying its observer', async () => { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => { + const attempt = attemptHarness(owner).attempt; + return { + ok: true, + value: { + ...attempt, + cancel: vi.fn(() => true), + } as RenderAttempt, + }; + }, + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + + batch.cancel(); + + await expect(batch.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(pending.signals[0]?.aborted).toBe(true); + }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 7d02acf3c..7c507c50b 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -254,6 +254,46 @@ describe('slot registry', () => { expect(service.snapshotForTest().records).toBe(MAX_ACTIVE_SLOT_RECORDS); }); + it('snapshots navigation-local registration order with detached programmatic auction units', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const directAuctionUnit = Object.freeze({ code: 'programmatic' }); + + expect( + service.register(navigation, [ + serverRegistration('server'), + { + directAuctionUnit, + registeredSlotId: 'programmatic', + source: 'programmatic', + }, + ]) + ).toMatchObject({ ok: true }); + expect(service.snapshotRegisteredSlots(navigation)).toEqual([ + expect.objectContaining({ ordinal: 0, registeredSlotId: 'server', source: 'server' }), + expect.objectContaining({ + directAuctionUnit, + ordinal: 1, + registeredSlotId: 'programmatic', + source: 'programmatic', + }), + ]); + expect(Object.isFrozen(service.snapshotRegisteredSlots(navigation))).toBe(true); + + expect( + service.register(navigation, [ + { + directAuctionUnit: { code: 'unfrozen' }, + registeredSlotId: 'unfrozen', + source: 'programmatic', + }, + ]) + ).toEqual({ ok: false, reason: 'invalid_slot_id' }); + + runtime.dispose(); + expect(service.snapshotRegisteredSlots(navigation)).toBeUndefined(); + }); + it('rejects exact registered-id collisions without partial indexes', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); From 9b766c39c0c0f3cc3523415c8f7e0d8fd898e949 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:51:27 -0700 Subject: [PATCH 308/494] Wire the test-only direct auction API --- .../lib/src/composition/browser.ts | 239 +++++++++++++++++- .../lib/src/core/contracts/request_ads.ts | 165 ++++++++++++ .../trusted-server-js/lib/src/core/request.ts | 163 +----------- .../lib/src/kernel/fallback.ts | 4 +- .../lib/test/composition/browser.test.ts | 148 +++++++++++ .../lib/test/core/request.test.ts | 7 + 6 files changed, 566 insertions(+), 160 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/contracts/request_ads.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 6f97ff093..89fe7ac98 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -18,11 +18,18 @@ import { type PrebidGlobalTarget, } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; +import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { + AdUnitRegistrationError, + addAdUnitsResult, + prepareProgrammaticAdUnits, +} from '../core/registry'; +import { validateRequestAdsOptions } from '../core/request'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; @@ -31,6 +38,11 @@ import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; import { createAuctionContextRegistry, type AuctionContextRegistry } from '../services/context'; +import { + createAuctionBatchService, + type AuctionBatchFetcher, + type AuctionBatchService, +} from '../services/auction_batch'; import { createPageBidsController, type PageBidsController, @@ -38,15 +50,18 @@ import { } from '../services/projections'; import { createReservationService, type ReservationService } from '../services/reservations'; import { + createCommittedArtifactStore, + createRenderAttempt, createRendererNonceRegistry, resolveCacheAdmAttempt, renderDirectCacheAttempt, renderDirectAdmAttempt, type RenderAttempt, + type CommittedArtifactStore, type RendererNonceRegistry, } from '../services/render'; import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; -import { createSlotService, type SlotService } from '../services/slots'; +import { createSlotService, type SlotRecord, type SlotService } from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; export interface BrowserAdapters { @@ -60,6 +75,8 @@ export interface BrowserComposition { } export interface BrowserServices { + readonly artifacts: CommittedArtifactStore; + readonly auctionBatches: AuctionBatchService; readonly pucBridge: PucBridge; readonly reservations: ReservationService; readonly rendererNonces: RendererNonceRegistry; @@ -109,6 +126,7 @@ export interface BrowserCoreActivations { } export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { + readonly auctionFetcherForTest?: AuctionBatchFetcher; readonly coreActivations: BrowserCoreActivations; readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; @@ -208,9 +226,177 @@ export function createTestBrowserRuntimeComposition( let preparedBrowserServices: PreparedBrowserServices | undefined; let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; + let auctionBatchService: AuctionBatchService | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; + const frozenSlotResult = (result: Record): Readonly> => + Object.freeze(result); + const combineRequestResults = ( + requestedSlots: readonly string[], + records: readonly (SlotRecord | undefined)[], + validResults: readonly Readonly>[] + ): Readonly<{ slots: readonly Readonly>[] }> => { + let validIndex = 0; + return Object.freeze({ + slots: Object.freeze( + requestedSlots.map((slot, index) => { + if (!records[index]) { + return frozenSlotResult({ + slot, + path: 'primary', + outcome: 'failed', + reason: 'slot_unresolved', + }); + } + const result = validResults[validIndex]; + validIndex += 1; + return ( + result ?? + frozenSlotResult({ + slot, + path: 'primary', + outcome: 'failed', + reason: 'internal_error', + }) + ); + }) + ), + }); + }; + const addProgrammaticAdUnits = (candidate: unknown): unknown => { + const navigation = runtimeSession?.currentNavigation; + const slots = browserServices?.slots; + const snapshot = navigation && slots?.snapshotRegisteredSlots(navigation); + if (!navigation || !slots || !snapshot) throw new Error('TSJS navigation is unavailable'); + const knownSlots = new Set(snapshot.map(({ registeredSlotId }) => registeredSlotId)); + const prepared = prepareProgrammaticAdUnits(candidate, knownSlots); + const registered = slots.register( + navigation, + prepared.map((unit) => ({ + directAuctionUnit: unit, + registeredSlotId: unit.code, + source: 'programmatic' as const, + })) + ); + if (!registered.ok) { + if (registered.reason === 'registry_capacity') { + throw new AdUnitRegistrationError('registry_capacity'); + } + if (registered.reason === 'duplicate_slot') { + throw new AdUnitRegistrationError('slot_collision'); + } + throw new Error('TSJS navigation changed during registration'); + } + return addAdUnitsResult(prepared); + }; + const requestDirectAds = (candidate?: unknown): Promise => { + let validated: ReturnType; + try { + validated = validateRequestAdsOptions(candidate); + } catch (error) { + return Promise.reject(error); + } + const navigation = runtimeSession?.currentNavigation; + const slots = browserServices?.slots; + const snapshot = navigation && slots?.snapshotRegisteredSlots(navigation); + if (!navigation || !slots || !snapshot) { + const requested = validated.slots ?? Object.freeze([]); + return Promise.resolve( + Object.freeze({ + slots: Object.freeze( + requested.map((slot) => + frozenSlotResult({ + slot, + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }) + ) + ), + }) + ); + } + + const recordsById = new Map(snapshot.map((record) => [record.registeredSlotId, record])); + const requestedSlots = Object.freeze( + validated.slots + ? Array.from(validated.slots) + : snapshot.map(({ registeredSlotId }) => registeredSlotId) + ); + const selectedRecords = Object.freeze(requestedSlots.map((slot) => recordsById.get(slot))); + const validRecords = selectedRecords.filter( + (record): record is SlotRecord => record !== undefined + ); + if (validRecords.length === 0) { + return Promise.resolve(combineRequestResults(requestedSlots, selectedRecords, [])); + } + + const context = auctionContextRegistry?.snapshot() ?? Object.freeze({}); + const adUnits = validRecords.map((record) => + record.directAuctionUnit + ? record.directAuctionUnit + : Object.freeze({ + code: record.registeredSlotId, + mediaTypes: Object.freeze({}), + bids: Object.freeze([]), + }) + ); + let requestBody: string; + try { + requestBody = JSON.stringify({ adUnits, config: context }); + if (new TextEncoder().encode(requestBody).byteLength > 256 * 1024) { + throw new Error('auction request body exceeds limit'); + } + } catch { + return Promise.resolve( + combineRequestResults( + requestedSlots, + selectedRecords, + validRecords.map((record) => + frozenSlotResult({ + slot: record.registeredSlotId, + path: 'primary', + outcome: 'failed', + reason: 'internal_error', + }) + ) + ) + ); + } + const batches = auctionBatchService; + if (!batches) { + return Promise.resolve( + combineRequestResults( + requestedSlots, + selectedRecords, + validRecords.map((record) => + frozenSlotResult({ + slot: record.registeredSlotId, + path: 'primary', + outcome: 'cancelled', + reason: 'navigation_disposed', + }) + ) + ) + ); + } + const batch = batches.create({ + navigation, + requestBody, + ...(validated.signal ? { signal: validated.signal } : {}), + slots: Object.freeze(validRecords.map(({ registeredSlotId }) => registeredSlotId)), + timeoutMs: validated.timeoutMs, + }); + return batch.result.then((result) => + combineRequestResults(requestedSlots, selectedRecords, result.slots) + ); + }; const runtime = createRuntime({ ...runtimeOptions, + kernel: { + addAdUnits: addProgrammaticAdUnits, + diagnostics: runtimeOptions.kernel.diagnostics, + requestAds: requestDirectAds, + }, activateOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; const cachePolicy = @@ -227,6 +413,7 @@ export function createTestBrowserRuntimeComposition( const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), }); + const artifacts = createCommittedArtifactStore(); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; const fetchCache = globalThis.fetch; @@ -318,7 +505,53 @@ export function createTestBrowserRuntimeComposition( return false; } }; + const resolveDirectContainer = (record: SlotRecord): HTMLElement | undefined => { + try { + if (typeof document === 'undefined' || record.domAliases.length === 0) return undefined; + const aliases = new Set(record.domAliases); + const matches = new Set(); + const elements = document.querySelectorAll('[id]'); + for (let index = 0; index < elements.length; index += 1) { + const element = elements.item(index); + if (element instanceof HTMLElement && aliases.has(element.id)) matches.add(element); + } + return matches.size === 1 ? Array.from(matches)[0] : undefined; + } catch { + return undefined; + } + }; + const fetchAuction = compositionOptions.auctionFetcherForTest ?? globalThis.fetch; + const batchCoordinator = createAuctionBatchService({ + ...(cachePolicy ? { cachePolicy } : {}), + createAttempt: (owner) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), + reservations: reservationService, + }), + fetcher: (input, init) => { + if (typeof fetchAuction !== 'function') return Promise.reject(new Error('unavailable')); + return fetchAuction(input, init); + }, + parseResponse: parseTrustedServerAuctionResponseV1, + renderWinner: (attempt) => { + const record = slotService.resolveRegisteredSlot(attempt.slot); + const container = record && resolveDirectContainer(record); + if (!container) { + attempt.fail('slot_unresolved'); + return false; + } + if (attempt.renderSource?.type === 'aps') return renderDirectAps(attempt, container); + if (attempt.renderSource?.type === 'adm') return renderDirectAdm(attempt, container); + if (attempt.renderSource?.type === 'cache') return renderDirectCache(attempt, container); + attempt.fail('winner_not_renderable'); + return false; + }, + }); const services = Object.freeze({ + artifacts, + auctionBatches: batchCoordinator, reservations: reservationService, rendererNonces, renderDirectAdm, @@ -339,7 +572,9 @@ export function createTestBrowserRuntimeComposition( interfaces: Object.freeze({ adapters: composition.adapters, ...services }), }); context.onDispose(() => { + batchCoordinator.dispose(); session.dispose(); + artifacts.dispose(); reservationService.dispose(); rendererNonces.dispose(); slotService.dispose(); @@ -350,6 +585,7 @@ export function createTestBrowserRuntimeComposition( runtimeSession = undefined; preparedBrowserServices = undefined; browserServices = undefined; + auctionBatchService = undefined; auctionContextRegistry = undefined; projectionParser = undefined; } @@ -375,6 +611,7 @@ export function createTestBrowserRuntimeComposition( runtimeOwner: session, }); runtimeSession = session; + auctionBatchService = batchCoordinator; auctionContextRegistry = contextRegistry; projectionParser = parseProjection; return runtimeOptions.activateOwner?.(context); diff --git a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts new file mode 100644 index 000000000..59b751b33 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -0,0 +1,165 @@ +const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; +const REQUEST_ADS_MAX_SLOTS = 256; +const abortSignalAbortedGetter = + typeof AbortSignal === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + +export type RequestAdsInputErrorCode = + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal'; + +export class RequestAdsInputError extends Error { + public readonly code: RequestAdsInputErrorCode; + + public constructor(code: RequestAdsInputErrorCode) { + super(code); + this.name = 'RequestAdsInputError'; + this.code = code; + } +} + +export interface ValidatedRequestAdsOptions { + readonly aborted: boolean; + readonly signal: AbortSignal | undefined; + readonly slots: readonly string[] | undefined; + readonly timeoutMs: number; +} + +function ownDataOptions(value: unknown): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const output: Record = Object.create(null) as Record; + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[key] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function ownDataSlots(value: unknown): readonly unknown[] | undefined { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !length || + !('value' in length) || + !Number.isSafeInteger(length.value) || + length.value < 0 || + length.value > REQUEST_ADS_MAX_SLOTS || + Object.getOwnPropertyNames(value).length !== length.value + 1 + ) { + return undefined; + } + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + output[index] = descriptor.value; + } + return output; + } catch { + return undefined; + } +} + +function readAbortSignal(signal: unknown): boolean | undefined { + try { + return typeof abortSignalAbortedGetter === 'function' + ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) + : undefined; + } catch { + return undefined; + } +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +/** Validate and detach the complete public request before creating attempts. */ +export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOptions { + if (value === undefined) { + return Object.freeze({ + aborted: false, + signal: undefined, + slots: undefined, + timeoutMs: REQUEST_ADS_DEFAULT_TIMEOUT_MS, + }); + } + const options = ownDataOptions(value); + if ( + !options || + !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') + ) { + throw new RequestAdsInputError('invalid_options'); + } + + let slots: readonly string[] | undefined; + if (Object.prototype.hasOwnProperty.call(options, 'slots')) { + const rawSlots = ownDataSlots(options.slots); + if (!rawSlots) throw new RequestAdsInputError('invalid_slots'); + if (rawSlots.length === 0) throw new RequestAdsInputError('empty_slots'); + const seen = new Set(); + const copy: string[] = []; + for (const slot of rawSlots) { + if ( + typeof slot !== 'string' || + slot.length === 0 || + new TextEncoder().encode(slot).byteLength > 256 || + hasAsciiControl(slot) || + /[\uD800-\uDFFF]/u.test(slot) + ) { + throw new RequestAdsInputError('invalid_slots'); + } + if (seen.has(slot)) throw new RequestAdsInputError('duplicate_slot'); + seen.add(slot); + copy.push(slot); + } + slots = Object.freeze(copy); + } + + let timeoutMs = REQUEST_ADS_DEFAULT_TIMEOUT_MS; + if (Object.prototype.hasOwnProperty.call(options, 'timeoutMs')) { + if ( + typeof options.timeoutMs !== 'number' || + !Number.isInteger(options.timeoutMs) || + options.timeoutMs < 100 || + options.timeoutMs > 30_000 + ) { + throw new RequestAdsInputError('invalid_timeout'); + } + timeoutMs = options.timeoutMs; + } + + let signal: AbortSignal | undefined; + let aborted = false; + if (Object.prototype.hasOwnProperty.call(options, 'signal')) { + const observed = readAbortSignal(options.signal); + if (observed === undefined) throw new RequestAdsInputError('invalid_signal'); + signal = options.signal as AbortSignal; + aborted = observed; + } + return Object.freeze({ aborted, signal, slots, timeoutMs }); +} diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index d0ba05ab8..ab469f8fd 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -8,163 +8,12 @@ import { getAllUnits, firstSize } from './registry'; import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace'; -const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; -const REQUEST_ADS_MAX_SLOTS = 256; -const abortSignalAbortedGetter = - typeof AbortSignal === 'undefined' - ? undefined - : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - -export type RequestAdsInputErrorCode = - | 'invalid_options' - | 'invalid_slots' - | 'empty_slots' - | 'duplicate_slot' - | 'invalid_timeout' - | 'invalid_signal'; - -export class RequestAdsInputError extends Error { - public readonly code: RequestAdsInputErrorCode; - - public constructor(code: RequestAdsInputErrorCode) { - super(code); - this.name = 'RequestAdsInputError'; - this.code = code; - } -} - -export interface ValidatedRequestAdsOptions { - readonly aborted: boolean; - readonly signal: AbortSignal | undefined; - readonly slots: readonly string[] | undefined; - readonly timeoutMs: number; -} - -function ownDataOptions(value: unknown): Record | undefined { - try { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; - const prototype = Object.getPrototypeOf(value) as unknown; - if (prototype !== Object.prototype && prototype !== null) return undefined; - if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const output: Record = Object.create(null) as Record; - for (const key of Object.getOwnPropertyNames(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; - output[key] = descriptor.value; - } - return output; - } catch { - return undefined; - } -} - -function ownDataSlots(value: unknown): readonly unknown[] | undefined { - try { - if ( - !Array.isArray(value) || - Object.getPrototypeOf(value) !== Array.prototype || - Object.getOwnPropertySymbols(value).length !== 0 - ) { - return undefined; - } - const length = Object.getOwnPropertyDescriptor(value, 'length'); - if ( - !length || - !('value' in length) || - !Number.isSafeInteger(length.value) || - length.value < 0 || - length.value > REQUEST_ADS_MAX_SLOTS || - Object.getOwnPropertyNames(value).length !== length.value + 1 - ) { - return undefined; - } - const output: unknown[] = []; - for (let index = 0; index < length.value; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; - output[index] = descriptor.value; - } - return output; - } catch { - return undefined; - } -} - -function readAbortSignal(signal: unknown): boolean | undefined { - try { - return typeof abortSignalAbortedGetter === 'function' - ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) - : undefined; - } catch { - return undefined; - } -} - -/** Validate and detach the complete public request before creating attempts. */ -export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOptions { - if (value === undefined) { - return Object.freeze({ - aborted: false, - signal: undefined, - slots: undefined, - timeoutMs: REQUEST_ADS_DEFAULT_TIMEOUT_MS, - }); - } - const options = ownDataOptions(value); - if ( - !options || - !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') - ) { - throw new RequestAdsInputError('invalid_options'); - } - - let slots: readonly string[] | undefined; - if (Object.prototype.hasOwnProperty.call(options, 'slots')) { - const rawSlots = ownDataSlots(options.slots); - if (!rawSlots) throw new RequestAdsInputError('invalid_slots'); - if (rawSlots.length === 0) throw new RequestAdsInputError('empty_slots'); - const seen = new Set(); - const copy: string[] = []; - for (const slot of rawSlots) { - if ( - typeof slot !== 'string' || - slot.length === 0 || - new TextEncoder().encode(slot).byteLength > 256 || - /[\p{Cc}]/u.test(slot) || - /[\uD800-\uDFFF]/u.test(slot) - ) { - throw new RequestAdsInputError('invalid_slots'); - } - if (seen.has(slot)) throw new RequestAdsInputError('duplicate_slot'); - seen.add(slot); - copy.push(slot); - } - slots = Object.freeze(copy); - } - - let timeoutMs = REQUEST_ADS_DEFAULT_TIMEOUT_MS; - if (Object.prototype.hasOwnProperty.call(options, 'timeoutMs')) { - if ( - typeof options.timeoutMs !== 'number' || - !Number.isInteger(options.timeoutMs) || - options.timeoutMs < 100 || - options.timeoutMs > 30_000 - ) { - throw new RequestAdsInputError('invalid_timeout'); - } - timeoutMs = options.timeoutMs; - } - - let signal: AbortSignal | undefined; - let aborted = false; - if (Object.prototype.hasOwnProperty.call(options, 'signal')) { - const observed = readAbortSignal(options.signal); - if (observed === undefined) throw new RequestAdsInputError('invalid_signal'); - signal = options.signal as AbortSignal; - aborted = observed; - } - return Object.freeze({ aborted, signal, slots, timeoutMs }); -} +export { + RequestAdsInputError, + type RequestAdsInputErrorCode, + type ValidatedRequestAdsOptions, + validateRequestAdsOptions, +} from './contracts/request_ads'; export type RequestAdsCallback = () => void; export interface LegacyRequestAdsOptions { diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index 7276ba00d..00ecf0206 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -1,12 +1,12 @@ import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; +import { validateRequestAdsOptions } from '../core/contracts/request_ads'; import { log } from '../core/log'; import { prepareProgrammaticAdUnits } from '../core/registry'; -import { validateRequestAdsOptions } from '../core/request'; import type { BootManifestV1 } from '../core/types'; export { AdUnitRegistrationError, type AdUnitRegistrationErrorCode } from '../core/registry'; -export { RequestAdsInputError, type RequestAdsInputErrorCode } from '../core/request'; +export { RequestAdsInputError, type RequestAdsInputErrorCode } from '../core/contracts/request_ads'; import type { BootFailureReason } from './integration_registry'; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 1939f954e..97bfcc5c5 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -820,4 +820,152 @@ describe('browser composition', () => { expect(latePreparation).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); + + it('exercises transactional addAdUnits and invocation-time requestAds snapshots through the test kernel', async () => { + const target = {}; + const requestBodies: Array<{ + adUnits: Array<{ code: string }>; + config: Readonly>; + }> = []; + const auctionFetcher = vi.fn(async (_input: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { + adUnits: Array<{ code: string }>; + config: Readonly>; + }; + requestBodies.push(body); + const slots = body.adUnits.map(({ code }) => code); + return { + ok: true, + json: async () => ({ + id: `auction-${requestBodies.length}`, + cur: 'USD', + seatbid: [], + ext: { + trusted_server: { + slot_results: { + version: 1, + auctionId: `auction-${requestBodies.length}`, + results: slots.map((slot) => ({ slot, outcome: 'no_bid' })), + }, + }, + }, + }), + }; + }); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: 'a'.repeat(64), + manifest: { + version: 1, + releaseId: 'a'.repeat(64), + integrations: [{ id: 'context_test', required: true }], + }, + knownIntegrationIds: Object.freeze(['context_test']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'server-slot', outcome: 'no_bid' }], + }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + auctionFetcherForTest: auctionFetcher, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration({ + id: 'context_test', + release: 'a'.repeat(64), + prepare: () => ({ activate: vi.fn() }), + }) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const contextContributor = vi.fn(() => ({ page: 'context' })); + const session = composition.runtimeSessionForTest(); + expect(session).toBeDefined(); + expect( + composition + .auctionContextRegistryForTest() + ?.register('context_test', contextContributor, session!) + ).toBe(true); + const api = target as { + addAdUnits(value: unknown): { readonly registered: readonly string[] }; + requestAds(options?: unknown): Promise<{ readonly slots: readonly object[] }>; + }; + const programmatic = { + code: 'programmatic-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: { placement: 7 } }], + }; + + expect(api.addAdUnits(programmatic)).toEqual({ registered: ['programmatic-slot'] }); + expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); + expect(() => + api.addAdUnits([ + { + code: 'must-roll-back', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + }, + { + code: 'server-slot', + mediaTypes: { banner: { sizes: [[1, 1]] } }, + }, + ]) + ).toThrowError(expect.objectContaining({ code: 'slot_collision', unitIndex: 1 })); + expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); + await expect(api.requestAds({ slots: ['unknown', 'programmatic-slot'] })).resolves.toEqual({ + slots: [ + { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(requestBodies[0]).toEqual({ + adUnits: [programmatic], + config: { page: 'context' }, + }); + expect(contextContributor).toHaveBeenCalledOnce(); + + const omitted = api.requestAds(); + expect( + api.addAdUnits({ + code: 'later-slot', + mediaTypes: { banner: { sizes: [[728, 90]] } }, + }) + ).toEqual({ registered: ['later-slot'] }); + await expect(omitted).resolves.toEqual({ + slots: [ + { slot: 'server-slot', path: 'primary', outcome: 'no_bid' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(requestBodies[1]?.adUnits.map(({ code }) => code)).toEqual([ + 'server-slot', + 'programmatic-slot', + ]); + expect(requestBodies[1]?.adUnits).not.toContainEqual( + expect.objectContaining({ code: 'later-slot' }) + ); + expect(requestBodies[1]?.config).toEqual({ page: 'context' }); + expect(contextContributor).toHaveBeenCalledTimes(2); + expect(auctionFetcher).toHaveBeenCalledTimes(2); + + composition.runtime.dispose(); + }); }); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 26db64b26..48625dcf0 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -86,6 +86,13 @@ describe('requestAds input contract', () => { timeoutMs: 100, }); }); + + it('uses the registered-slot ASCII-control grammar instead of rejecting other Unicode controls', () => { + expect(validateRequestAdsOptions({ slots: ['slot\u0085id'] })).toMatchObject({ + slots: ['slot\u0085id'], + }); + expectInputError(() => validateRequestAdsOptions({ slots: ['slot\u007fid'] }), 'invalid_slots'); + }); }); describe('request.requestAds', () => { From e5a134101fe8fe6ae35f9764b18381c47aa9bdf9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:59:20 -0700 Subject: [PATCH 309/494] Publish the hard-cutover TSJS types --- .../lib/src/core/global.d.ts | 6 +- .../trusted-server-js/lib/src/core/index.ts | 12 +- .../trusted-server-js/lib/src/core/trace.ts | 6 +- .../trusted-server-js/lib/src/core/types.ts | 103 +++++++++++++++++- crates/trusted-server-js/lib/src/index.ts | 27 ++++- .../lib/src/integrations/aps/render.ts | 4 +- .../lib/src/integrations/gpt/index.ts | 24 ++-- .../src/integrations/gpt_diagnostics/index.ts | 4 +- .../lib/src/integrations/testlight/index.ts | 10 +- .../lib/src/shared/globals.ts | 4 +- .../lib/test/core/index.test.ts | 18 +-- .../lib/test/core/public_types.test.ts | 43 ++++++++ .../lib/test/core/trace.test.ts | 16 +-- .../lib/test/integrations/gpt/ad_init.test.ts | 8 +- .../integrations/gpt/gpt_bootstrap.test.ts | 6 +- .../lib/test/integrations/gpt/index.test.ts | 6 +- .../gpt/schedule_initial_ad_init.test.ts | 4 +- .../test/integrations/gpt/spa_hook.test.ts | 4 +- .../gpt_diagnostics/index.test.ts | 4 +- .../test/integrations/prebid/index.test.ts | 8 +- 20 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/core/public_types.test.ts diff --git a/crates/trusted-server-js/lib/src/core/global.d.ts b/crates/trusted-server-js/lib/src/core/global.d.ts index 9b21ab312..21fbb29da 100644 --- a/crates/trusted-server-js/lib/src/core/global.d.ts +++ b/crates/trusted-server-js/lib/src/core/global.d.ts @@ -1,10 +1,10 @@ -import type { TsjsApi } from './types'; +import type { LegacyTsjsApi } from './types'; declare global { interface Window { /** Publisher-owned object identity is retained through dormant Task 8 bootstrap tests. */ - tsjs?: TsjsApi; - pbjs?: TsjsApi; + tsjs?: LegacyTsjsApi; + pbjs?: LegacyTsjsApi; } } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 2806354b3..807292008 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -4,11 +4,11 @@ export type { GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, - TsjsApi, + LegacyTsjsApi, } from './types'; // Erased coordinated-cutover types only. Production ownership remains below until Task 19. export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; -import type { TsjsApi } from './types'; +import type { LegacyTsjsApi } from './types'; import { addAdUnits } from './registry'; import { renderAdUnit, renderAllAdUnits } from './render'; import { log } from './log'; @@ -18,16 +18,16 @@ import { installQueue } from './queue'; const VERSION = '0.1.0'; -const w: Window & { tsjs?: TsjsApi } = +const w: Window & { tsjs?: LegacyTsjsApi } = ((globalThis as unknown as { window?: Window }).window as Window & { - tsjs?: TsjsApi; - }) || ({} as Window & { tsjs?: TsjsApi }); + tsjs?: LegacyTsjsApi; + }) || ({} as Window & { tsjs?: LegacyTsjsApi }); // Collect existing tsjs queued fns before we overwrite const pending: Array<() => void> = Array.isArray(w.tsjs?.que) ? [...w.tsjs.que] : []; // Create API and attach methods -const api: TsjsApi = (w.tsjs ??= {} as TsjsApi); +const api: LegacyTsjsApi = (w.tsjs ??= {} as LegacyTsjsApi); api.version = VERSION; api.addAdUnits = addAdUnits; api.renderAdUnit = renderAdUnit; diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 2acc50900..67c9d07d6 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -8,7 +8,7 @@ // that creatives came through Trusted Server — on both the SSAT/GAM and // /auction render paths. import { log } from './log'; -import type { RenderRecord, TsjsApi } from './types'; +import type { LegacyTsjsApi, RenderRecord } from './types'; /** CustomEvent fired on window after each render-trace record is written. */ export const RENDER_EVENT_NAME = 'tsjs:adRendered'; @@ -48,7 +48,7 @@ let fallbackRenderSeq = 0; */ function nextRenderSeq(): number { try { - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); const next = Math.max(ts.renderSeq ?? 0, fallbackRenderSeq) + 1; ts.renderSeq = next; fallbackRenderSeq = next; @@ -445,7 +445,7 @@ export function renderTracePanel(): void { export function recordRender(record: Omit): RenderRecord { const full: RenderRecord = { ...record, count: 1, seq: nextRenderSeq(), at: Date.now() }; try { - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); const renders = (ts.renders ??= {}); const prev = renders[record.slotId]; if (prev) full.count = prev.count + 1; diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 9335dda00..7f8d5c798 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -414,7 +414,108 @@ export interface RequestAdsResult { readonly slots: readonly RequestAdsSlotResult[]; } -export interface TsjsApi { +export type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + +export interface TsjsLog { + setLevel(level: TsjsLogLevel): void; + getLevel(): TsjsLogLevel; + error(...values: readonly unknown[]): void; + warn(...values: readonly unknown[]): void; + info(...values: readonly unknown[]): void; + debug(...values: readonly unknown[]): void; +} + +export interface TsjsCommandQueue { + readonly length: 0; + push(callback: unknown): 0; +} + +export interface CreativeBootV1 { + readonly version: 1; + readonly enabled: boolean; + readonly clickGuard: boolean; + readonly renderGuard: boolean; +} + +export interface DiagnosticsBootV1 { + readonly version: 1; + readonly renderTraceOverlay: boolean; + readonly gpt: Readonly<{ readonly active: boolean }>; +} + +export interface TsjsBootV1 { + readonly abi: 1; + readonly releaseId: string; + readonly manifest: Readonly; + readonly auctionProjection: Readonly; + readonly cachePolicy?: Readonly; + readonly creative: Readonly; + readonly diagnostics: Readonly; +} + +export type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh'; +export type RenderTraceServedFromV1 = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid'; + +export interface RenderTraceRecord { + readonly slotId: string; + readonly path: RenderTracePathV1; + readonly rendered: boolean; + readonly elementId?: string; + readonly auctionId?: string; + readonly bidder?: string; + readonly adId?: string; + readonly bidId?: string; + readonly creativeId?: string; + readonly admHash?: string; + readonly servedFrom?: RenderTraceServedFromV1; + readonly gamEmpty?: boolean; + readonly injected?: boolean; + readonly visible?: boolean; + readonly count: number; + readonly seq: number; + readonly at: number; +} + +export interface RenderTraceDiagnostics { + current(): Readonly>>; + history(): readonly Readonly[]; + subscribe(listener: (record: Readonly) => void): () => void; +} + +export interface TsjsDiagnostics { + readonly renderTrace: RenderTraceDiagnostics; + readonly gpt?: GptDiagnosticsApi; +} + +export interface TsjsApiBase { + readonly version: '1.0.0'; + readonly releaseId: string; + readonly boot: Readonly; + readonly que: TsjsCommandQueue; + readonly log: TsjsLog; + readonly _registerIntegration: (registration: unknown) => false; + addAdUnits(units: ProgrammaticAdUnit | readonly ProgrammaticAdUnit[]): AddAdUnitsResult; + requestAds(options?: RequestAdsOptions): Promise; +} + +export interface TsjsKernelApi extends TsjsApiBase { + readonly diagnostics: Readonly; + readonly _internal: Readonly<{ state: 'kernel'; releaseId: string }>; +} + +export interface TsjsFallbackApi extends TsjsApiBase { + readonly diagnostics?: never; + readonly _internal: Readonly<{ + state: 'fallback'; + releaseId: string; + reason: 'abi_mismatch' | 'bundle_partial'; + }>; +} + +export type TsjsApi = TsjsKernelApi | TsjsFallbackApi; + +/** Pre-cutover bundle implementation shape. Deleted with the unreachable legacy core. */ +export interface LegacyTsjsApi { version: string; que: Array<() => void>; addAdUnits(units: AdUnit | AdUnit[]): void; diff --git a/crates/trusted-server-js/lib/src/index.ts b/crates/trusted-server-js/lib/src/index.ts index aa0f7931d..74caed3a8 100644 --- a/crates/trusted-server-js/lib/src/index.ts +++ b/crates/trusted-server-js/lib/src/index.ts @@ -1,11 +1,28 @@ -// Barrel re-export for convenience and tests. -// At build time, each module (core + integrations) is built as a separate IIFE -// by build-all.mjs. The Rust server concatenates the enabled modules at runtime. export type { - AdUnit, + AddAdUnitsResult, + CreativeBootV1, + DiagnosticsBootV1, GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, + ProgrammaticAdUnit, + RenderFailureReason, + RenderTraceDiagnostics, + RenderTracePathV1, + RenderTraceRecord, + RenderTraceServedFromV1, + RequestAdsOptions, + RequestAdsResult, + RequestAdsSlotResult, TsjsApi, + TsjsBootV1, + TsjsCommandQueue, + TsjsDiagnostics, + TsjsFallbackApi, + TsjsKernelApi, + TsjsLog, + TsjsLogLevel, } from './core/types'; -export { log } from './core/log'; +export { AdUnitRegistrationError, type AdUnitRegistrationErrorCode } from './core/registry'; +export { RequestAdsInputError, type RequestAdsInputErrorCode } from './core/contracts/request_ads'; +export { TsjsUnavailableError } from './kernel/fallback'; diff --git a/crates/trusted-server-js/lib/src/integrations/aps/render.ts b/crates/trusted-server-js/lib/src/integrations/aps/render.ts index 3421ab579..ecf3566af 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import type { ApsPrebidRendererEntry, ApsRendererV1, LegacyTsjsApi } from '../../core/types'; import { validateApsRenderer } from '../../core/contracts/aps_renderer'; import type { MessagingAdapter, MessagingChannel } from '../../adapters/messaging'; import type { @@ -164,7 +164,7 @@ export function registerApsPrebidRenderer( typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? Math.min(ttlSeconds, MAX_PREBID_RENDERER_TTL_SECONDS) : DEFAULT_PREBID_RENDERER_TTL_SECONDS; - const tsjs = (window.tsjs ??= {} as TsjsApi); + const tsjs = (window.tsjs ??= {} as LegacyTsjsApi); const registry = (tsjs.apsPrebidRenderers ??= Object.create(null) as Record< string, ApsPrebidRendererEntry diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index 22e28d5e8..3848087ff 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -6,7 +6,7 @@ import type { AuctionBidData, BrowserAuctionBidV1, GptSlotHandoff, - TsjsApi, + LegacyTsjsApi, } from '../../core/types'; import { APS_UNIVERSAL_CREATIVE_RENDERER, @@ -78,7 +78,7 @@ export function prepareTrustedServerGptTargetingV1( return Object.freeze(targeting); } -function bumpRenderGeneration(ts: TsjsApi): number { +function bumpRenderGeneration(ts: LegacyTsjsApi): number { const next = (ts.renderGeneration ?? 0) + 1; ts.renderGeneration = next; return next; @@ -572,7 +572,7 @@ function queueWinBillingBeacon(url: string): boolean { * `installTsAdInit` runs, so the detector is still queued ahead of the * publisher's GPT setup. */ -function syncInitialLoadDisabled(gpt: Partial, ts: TsjsApi): boolean { +function syncInitialLoadDisabled(gpt: Partial, ts: LegacyTsjsApi): boolean { if (typeof gpt.getConfig !== 'function') return false; const config = gpt.getConfig('disableInitialLoad'); @@ -582,7 +582,7 @@ function syncInitialLoadDisabled(gpt: Partial, ts: TsjsApi): boolean return true; } -function installInitialLoadDetector(ts: TsjsApi): void { +function installInitialLoadDetector(ts: LegacyTsjsApi): void { const win = window as GptWindow; const cmd = win.googletag?.cmd; if (!cmd) return; @@ -635,7 +635,7 @@ function findGptSlotByElementId( return pubads.getSlots?.().find((slot) => slot.getSlotElementId() === elementId); } -function handoffForSlot(ts: TsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { +function handoffForSlot(ts: LegacyTsjsApi, slot: GoogleTagSlot): GptSlotHandoff | undefined { return ts.gptSlotHandoffs?.[slot.getSlotElementId()]; } @@ -658,7 +658,7 @@ function handoffFormatsMatch(handoff: GptSlotHandoff, formats: Array, @@ -680,11 +680,11 @@ function matchingHandoff( return matching.length === 1 ? matching[0] : undefined; } -function registerHandoffAlias(ts: TsjsApi, elementId: string, handoff: GptSlotHandoff): void { +function registerHandoffAlias(ts: LegacyTsjsApi, elementId: string, handoff: GptSlotHandoff): void { (ts.gptSlotHandoffs ??= {})[elementId] = handoff; } -function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { +function withGptSlotHandoffInternal(ts: LegacyTsjsApi, callback: () => T): T { const wasInternal = ts.gptSlotHandoffInternal; ts.gptSlotHandoffInternal = true; try { @@ -704,7 +704,7 @@ function withGptSlotHandoffInternal(ts: TsjsApi, callback: () => T): T { * the original div is gone. The first duplicate publisher request is suppressed * because TS has already issued the initial request with TS targeting. */ -function installLatePublisherSlotHandoff(ts: TsjsApi): void { +function installLatePublisherSlotHandoff(ts: LegacyTsjsApi): void { const win = window as GptWindow; const cmd = win.googletag?.cmd; if (!cmd) return; @@ -859,7 +859,7 @@ function installLatePublisherSlotHandoff(ts: TsjsApi): void { * riding rAF keeps a single code path whose post-hydration-commit guarantee * holds whenever the request is actually issued. */ -function installScheduleInitialAdInit(ts: TsjsApi): void { +function installScheduleInitialAdInit(ts: LegacyTsjsApi): void { ts.scheduleInitialAdInit = function (initialBids?: Record) { if ((ts.navGeneration ?? 0) !== 0) return; if (initialBids) ts.bids = initialBids; @@ -881,7 +881,7 @@ function installScheduleInitialAdInit(ts: TsjsApi): void { } export function installTsAdInit(): void { - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); installInitialLoadDetector(ts); installScheduleInitialAdInit(ts); installLatePublisherSlotHandoff(ts); @@ -1289,7 +1289,7 @@ function waitForSlotElements(slots: AuctionSlot[], signal: AbortSignal): Promise */ export function installSpaAuctionHook(): void { if (typeof window === 'undefined') return; - const ts = (window.tsjs ??= {} as TsjsApi); + const ts = (window.tsjs ??= {} as LegacyTsjsApi); if (ts.spaHookInstalled) return; ts.spaHookInstalled = true; // Navigation identity for the deferred initial-adInit bootstrap (see diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index 5cb5c9daf..1265585b0 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,5 +1,5 @@ import { log } from '../../core/log'; -import type { GptDiagnosticsApi, TsjsApi } from '../../core/types'; +import type { GptDiagnosticsApi, LegacyTsjsApi } from '../../core/types'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; @@ -19,7 +19,7 @@ type GptDiagnosticsWindow = Window & GptObserverWindow & { __tsjs_gpt_diagnostics_active?: boolean; __tsjs_gpt_diagnostics_runtime?: GptDiagnosticsRuntime; - tsjs?: TsjsApi; + tsjs?: LegacyTsjsApi; }; /** Whether the early bootstrap activated diagnostics for this document. */ diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts index 7f2598b17..8424d20af 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts @@ -1,4 +1,4 @@ -import type { TsjsApi } from '../../core/types'; +import type { LegacyTsjsApi } from '../../core/types'; import { installQueue } from '../../core/queue'; import { log } from '../../core/log'; import { resolvePrebidWindow } from '../../shared/globals'; @@ -14,9 +14,9 @@ type TestlightWindow = PrebidWindow & { testlight?: TestlightGlobal; }; -function ensureTsjsApi(win: TestlightWindow): TsjsApi { +function ensureTsjsApi(win: TestlightWindow): LegacyTsjsApi { if (win.tsjs) return win.tsjs; - const stub: TsjsApi = { + const stub: LegacyTsjsApi = { version: '0.0.0', que: [], addAdUnits: () => undefined, @@ -27,13 +27,13 @@ function ensureTsjsApi(win: TestlightWindow): TsjsApi { return stub; } -function installTestlightQueue(api: TsjsApi, win: TestlightWindow): void { +function installTestlightQueue(api: LegacyTsjsApi, win: TestlightWindow): void { if (!Array.isArray(api.que)) { installQueue(api, win); } } -function flushCallbacks(queue: TestlightCallback[], api: TsjsApi): void { +function flushCallbacks(queue: TestlightCallback[], api: LegacyTsjsApi): void { while (queue.length > 0) { const fn = queue.shift(); if (typeof fn !== 'function') { diff --git a/crates/trusted-server-js/lib/src/shared/globals.ts b/crates/trusted-server-js/lib/src/shared/globals.ts index cbacb590f..7bb838d82 100644 --- a/crates/trusted-server-js/lib/src/shared/globals.ts +++ b/crates/trusted-server-js/lib/src/shared/globals.ts @@ -1,5 +1,5 @@ // Cross-runtime helpers for resolving windows/globals in creatives and pbjs shims. -import type { TsjsApi } from '../core/types'; +import type { LegacyTsjsApi } from '../core/types'; export interface TsCreativeApi { installGuards(): void; @@ -34,7 +34,7 @@ export function resolveWindow(): Window | undefined { return maybeWindow; } -export type PrebidWindow = Window & { tsjs?: TsjsApi; pbjs?: TsjsApi }; +export type PrebidWindow = Window & { tsjs?: LegacyTsjsApi; pbjs?: LegacyTsjsApi }; // Always hand back an object so shims can safely assign tsjs/pbjs globals. export function resolvePrebidWindow(): PrebidWindow { diff --git a/crates/trusted-server-js/lib/test/core/index.test.ts b/crates/trusted-server-js/lib/test/core/index.test.ts index a02082b59..a46efa57c 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type { AuctionBidData, AuctionSlot, TsjsApi } from '../../src/core/types'; +import type { AuctionBidData, AuctionSlot, LegacyTsjsApi } from '../../src/core/types'; const ORIGINAL_FETCH = global.fetch; @@ -17,7 +17,7 @@ describe('core/index', () => { it('initializes tsjs API with expected surface', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; expect(api).toBeDefined(); expect(typeof api.version).toBe('string'); expect(Array.isArray(api.que)).toBe(true); @@ -31,7 +31,7 @@ describe('core/index', () => { it('defaults adSlots and bids so gated-off pages never see undefined', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; expect(api.adSlots).toEqual([]); expect(api.bids).toEqual({}); }); @@ -40,7 +40,7 @@ describe('core/index', () => { window.tsjs = { adSlots: [{ id: 'pre-injected' } as AuctionSlot], bids: { 'pre-injected': { hb_pb: '1.00' } } as Record, - } as TsjsApi; + } as LegacyTsjsApi; await import('../../src/core/index'); @@ -49,10 +49,10 @@ describe('core/index', () => { }); it('flushes queued callbacks that existed before initialization', async () => { - const callback = vi.fn(function (this: TsjsApi) { + const callback = vi.fn(function (this: LegacyTsjsApi) { expect(this).toBe(window.tsjs); }); - window.tsjs = { que: [callback] as Array<() => void> } as TsjsApi; + window.tsjs = { que: [callback] as Array<() => void> } as LegacyTsjsApi; await import('../../src/core/index'); @@ -61,7 +61,7 @@ describe('core/index', () => { it('installs queue that executes callbacks immediately with api context', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; const fn = vi.fn(); api.que.push(fn); @@ -72,7 +72,7 @@ describe('core/index', () => { it('renders registered ad units using core rendering helpers', async () => { await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; api.addAdUnits([ { code: 'slot-1', mediaTypes: { banner: { sizes: [[300, 250]] } } }, @@ -88,7 +88,7 @@ describe('core/index', () => { it('exposes requestAds from the core request module', async () => { const { requestAds } = await import('../../src/core/request'); await import('../../src/core/index'); - const api = window.tsjs as TsjsApi; + const api = window.tsjs as LegacyTsjsApi; expect(api.requestAds).toBe(requestAds); }); diff --git a/crates/trusted-server-js/lib/test/core/public_types.test.ts b/crates/trusted-server-js/lib/test/core/public_types.test.ts new file mode 100644 index 000000000..21a5f037d --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/public_types.test.ts @@ -0,0 +1,43 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { + AddAdUnitsResult, + ProgrammaticAdUnit, + RequestAdsOptions, + RequestAdsResult, + TsjsApi, + TsjsCommandQueue, + TsjsDiagnostics, + TsjsLog, +} from '../../src'; + +describe('public hard-cutover types', () => { + it('exports the exact Promise API without legacy helper names', () => { + type ExpectedKeys = + | 'version' + | 'releaseId' + | 'boot' + | 'que' + | 'log' + | '_registerIntegration' + | 'addAdUnits' + | 'requestAds' + | 'diagnostics' + | '_internal'; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'1.0.0'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf['diagnostics']>().toEqualTypeOf< + Readonly + >(); + expectTypeOf().parameters.toEqualTypeOf< + [ProgrammaticAdUnit | readonly ProgrammaticAdUnit[]] + >(); + expectTypeOf().returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + (options?: RequestAdsOptions) => Promise + >(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts index 3317f67e1..6c80f2e6f 100644 --- a/crates/trusted-server-js/lib/test/core/trace.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -12,7 +12,7 @@ import { TRACE_PANEL_ID, TRACE_BADGE_CLASS, } from '../../src/core/trace'; -import type { RenderRecord, TsjsApi } from '../../src/core/types'; +import type { LegacyTsjsApi, RenderRecord } from '../../src/core/types'; function clearTraceCookie(): void { document.cookie = 'ts-trace=; Max-Age=0; Path=/'; @@ -24,7 +24,7 @@ function removePanel(): void { describe('trace/recordRender', () => { beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; + delete (window as { tsjs?: LegacyTsjsApi }).tsjs; clearTraceCookie(); removePanel(); }); @@ -215,7 +215,7 @@ describe('trace/floating panel', () => { }; beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; + delete (window as { tsjs?: LegacyTsjsApi }).tsjs; clearTraceCookie(); removePanel(); }); @@ -377,10 +377,10 @@ describe('trace/floating panel', () => { document.cookie = 'ts-trace=1; Path=/'; const oldRecord = { ...record, auctionId: 'auction-old', count: 1, seq: 7, at: 1 }; const liveRecord = { ...record, auctionId: 'auction-live', count: 2, seq: 7, at: 2 }; - (window as { tsjs?: TsjsApi }).tsjs = { + (window as { tsjs?: LegacyTsjsApi }).tsjs = { renders: { 'slot-1': liveRecord }, renderLog: [oldRecord, liveRecord], - } as unknown as TsjsApi; + } as unknown as LegacyTsjsApi; renderTracePanel(); @@ -403,9 +403,9 @@ describe('trace/floating panel', () => { }); it('renderTracePanel is a no-op while disarmed even if renders exist', () => { - (window as { tsjs?: TsjsApi }).tsjs = { + (window as { tsjs?: LegacyTsjsApi }).tsjs = { renders: { 'slot-1': { ...record, count: 1, seq: 1, at: 1 } }, - } as unknown as TsjsApi; + } as unknown as LegacyTsjsApi; renderTracePanel(); expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); }); @@ -429,7 +429,7 @@ describe('trace/floating panel', () => { describe('trace/confirmation badge', () => { beforeEach(() => { - delete (window as { tsjs?: TsjsApi }).tsjs; + delete (window as { tsjs?: LegacyTsjsApi }).tsjs; clearTraceCookie(); document.body.innerHTML = ''; }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts index 71ee3bc5f..7bd23fdfd 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts @@ -8,7 +8,7 @@ import type { BidRenderSourceV1, BrowserAuctionBidV1, GptSlotHandoff, - TsjsApi, + LegacyTsjsApi, } from '../../../src/core/types'; function apsRenderer() { @@ -143,11 +143,11 @@ interface PrebidResponseMessage { height?: number; } -// `tsjs` is declared globally as the full `TsjsApi` (core/types.ts). Omitting +// `tsjs` is declared globally as the full legacy API (core/types.ts). Omitting // it from `Window` before re-adding it as a `Partial` avoids the intersection -// that would force every fixture below to satisfy the whole `TsjsApi` shape. +// that would force every fixture below to satisfy the whole legacy API shape. type TestGptSlotHandoff = Omit & { formats: number[][] }; -type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { +type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { gptSlotHandoffs?: Record | undefined; }; type TestWindow = Omit & { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 587cf59cf..79c86fef0 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; /** * Executable coverage for the edge-injected `gpt_bootstrap.js` — the @@ -46,11 +46,11 @@ interface MockGoogleTag { display: (divId: string) => void; } -// `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from +// `tsjs` is declared globally as the full legacy API; `Omit` drops it from // `Window` so the fixtures below only have to satisfy the fields they set. type TestWindow = Omit & { googletag?: MockGoogleTag; - tsjs?: Partial; + tsjs?: Partial; }; function runBootstrap(): void { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts index 82ddc5b88..d50b697f4 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mock } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; // We import installGptShim dynamically so each test can control whether the // GPT enable flag is present before module evaluation. @@ -244,10 +244,10 @@ describe('GPT – installTsAdInit', () => { enableServices: Mock; } - // `tsjs` is declared globally as the full `TsjsApi`; `Omit` drops it from + // `tsjs` is declared globally as the full legacy API; `Omit` drops it from // `Window` so the fixture below only has to satisfy the fields it sets. type AdInitWindow = Omit & { - tsjs?: Partial; + tsjs?: Partial; googletag?: MockGoogleTag; }; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts index 889c189ea..07bfde6f5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; type TestWindow = Window & { googletag?: unknown; - tsjs?: TsjsApi; + tsjs?: LegacyTsjsApi; }; const originalPushState = history.pushState.bind(history); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index f1cb4d84f..139edcd34 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; type TestWindow = Window & { googletag?: unknown; - tsjs?: TsjsApi; + tsjs?: LegacyTsjsApi; }; const originalPushState = history.pushState.bind(history); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index 69a4508f3..5badf7638 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { TsjsApi } from '../../../src/core/types'; +import type { LegacyTsjsApi } from '../../../src/core/types'; import { installGptDiagnosticsRuntime, isGptDiagnosticsActive, @@ -18,7 +18,7 @@ type DiagnosticsTestWindow = NonNullable | undefined; + tsjs?: Partial | undefined; googletag?: unknown; __tsjs_prebid?: Record | undefined; __tsjsPrebidShimInstalled?: boolean | undefined; @@ -197,7 +197,11 @@ import { prepareTrustedServerPrebidBidV1, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; -import type { BidRenderSourceV1, BrowserAuctionBidV1, TsjsApi } from '../../../src/core/types'; +import type { + BidRenderSourceV1, + BrowserAuctionBidV1, + LegacyTsjsApi, +} from '../../../src/core/types'; import { log } from '../../../src/core/log'; import envelope from '../../fixtures/aps-renderer-v1.json'; From 55a29318c5a9c61520d355e5289adf5f03acc281 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:03:38 -0700 Subject: [PATCH 310/494] Harden direct auction identity boundaries --- .../lib/src/composition/browser.ts | 77 ++++++++---- .../lib/src/services/auction_batch.ts | 65 ++++++++-- .../lib/src/services/slots.ts | 18 ++- .../lib/test/composition/browser.test.ts | 111 +++++++++++++++++- .../lib/test/core/registry.test.ts | 29 +++++ .../lib/test/services/auction_batch.test.ts | 62 ++++++++++ .../lib/test/services/slots.test.ts | 8 +- 7 files changed, 321 insertions(+), 49 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 89fe7ac98..a99a80e22 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -24,12 +24,12 @@ import { parseBrowserAuctionProjectionV1, } from '../core/contracts/auction_projection'; import { validateApsRenderer } from '../core/contracts/aps_renderer'; +import { validateRequestAdsOptions } from '../core/contracts/request_ads'; import { AdUnitRegistrationError, addAdUnitsResult, prepareProgrammaticAdUnits, } from '../core/registry'; -import { validateRequestAdsOptions } from '../core/request'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; @@ -61,7 +61,12 @@ import { type RendererNonceRegistry, } from '../services/render'; import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; -import { createSlotService, type SlotRecord, type SlotService } from '../services/slots'; +import { + createSlotService, + type SlotRecord, + type SlotRegistrationFailure, + type SlotService, +} from '../services/slots'; import { createTargetingService, type TargetingService } from '../services/targeting'; export interface BrowserAdapters { @@ -262,30 +267,45 @@ export function createTestBrowserRuntimeComposition( ), }); }; + const registrationError = (reason: SlotRegistrationFailure): AdUnitRegistrationError => { + switch (reason) { + case 'invalid_slot_id': + return new AdUnitRegistrationError('invalid_code'); + case 'registry_capacity': + return new AdUnitRegistrationError('registry_capacity'); + case 'duplicate_slot': + case 'slot_quarantined': + case 'stale_owner': + return new AdUnitRegistrationError('slot_collision'); + } + }; const addProgrammaticAdUnits = (candidate: unknown): unknown => { const navigation = runtimeSession?.currentNavigation; const slots = browserServices?.slots; - const snapshot = navigation && slots?.snapshotRegisteredSlots(navigation); - if (!navigation || !slots || !snapshot) throw new Error('TSJS navigation is unavailable'); + if (!navigation || !slots) throw new AdUnitRegistrationError('slot_collision'); + let snapshot: readonly SlotRecord[] | undefined; + try { + snapshot = slots.snapshotRegisteredSlots(navigation); + } catch { + throw new AdUnitRegistrationError('slot_collision'); + } + if (!snapshot) throw new AdUnitRegistrationError('slot_collision'); const knownSlots = new Set(snapshot.map(({ registeredSlotId }) => registeredSlotId)); const prepared = prepareProgrammaticAdUnits(candidate, knownSlots); - const registered = slots.register( - navigation, - prepared.map((unit) => ({ - directAuctionUnit: unit, - registeredSlotId: unit.code, - source: 'programmatic' as const, - })) - ); - if (!registered.ok) { - if (registered.reason === 'registry_capacity') { - throw new AdUnitRegistrationError('registry_capacity'); - } - if (registered.reason === 'duplicate_slot') { - throw new AdUnitRegistrationError('slot_collision'); - } - throw new Error('TSJS navigation changed during registration'); + let registered: ReturnType; + try { + registered = slots.register( + navigation, + prepared.map((unit) => ({ + directAuctionUnit: unit, + registeredSlotId: unit.code, + source: 'programmatic' as const, + })) + ); + } catch { + throw new AdUnitRegistrationError('slot_collision'); } + if (!registered.ok) throw registrationError(registered.reason); return addAdUnitsResult(prepared); }; const requestDirectAds = (candidate?: unknown): Promise => { @@ -507,13 +527,19 @@ export function createTestBrowserRuntimeComposition( }; const resolveDirectContainer = (record: SlotRecord): HTMLElement | undefined => { try { - if (typeof document === 'undefined' || record.domAliases.length === 0) return undefined; - const aliases = new Set(record.domAliases); + if (typeof document === 'undefined') return undefined; + const identifiers = + record.source === 'programmatic' + ? new Set([record.registeredSlotId]) + : new Set(record.domAliases); + if (identifiers.size === 0) return undefined; const matches = new Set(); const elements = document.querySelectorAll('[id]'); for (let index = 0; index < elements.length; index += 1) { const element = elements.item(index); - if (element instanceof HTMLElement && aliases.has(element.id)) matches.add(element); + if (element instanceof HTMLElement && identifiers.has(element.id)) { + matches.add(element); + } } return matches.size === 1 ? Array.from(matches)[0] : undefined; } catch { @@ -527,7 +553,10 @@ export function createTestBrowserRuntimeComposition( createRenderAttempt({ artifacts, owner, - prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate, cachePolicy); + return source ? Object.freeze(source) : undefined; + }, reservations: reservationService, }), fetcher: (input, init) => { diff --git a/crates/trusted-server-js/lib/src/services/auction_batch.ts b/crates/trusted-server-js/lib/src/services/auction_batch.ts index 24dee8c92..eb7048e07 100644 --- a/crates/trusted-server-js/lib/src/services/auction_batch.ts +++ b/crates/trusted-server-js/lib/src/services/auction_batch.ts @@ -9,6 +9,23 @@ import type { } from './render'; const DEFAULT_AUCTION_ENDPOINT = '/auction'; +const reflectApplyIntrinsic = Reflect.apply; +function captureAbortSignalMethod(name: 'addEventListener' | 'removeEventListener'): unknown { + if (typeof AbortSignal === 'undefined') return undefined; + let prototype: object | null = AbortSignal.prototype; + while (prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + if (descriptor && 'value' in descriptor) return descriptor.value; + prototype = Object.getPrototypeOf(prototype) as object | null; + } + return undefined; +} +const abortSignalAbortedGetter = + typeof AbortSignal === 'undefined' + ? undefined + : Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; +const abortSignalAddEventListener = captureAbortSignalMethod('addEventListener'); +const abortSignalRemoveEventListener = captureAbortSignalMethod('removeEventListener'); export type AuctionBatchFetcher = (input: string, init: RequestInit) => Promise; @@ -114,6 +131,34 @@ function cancelledResult(slot: string, reason: RenderCancellationReason): Auctio return terminalResult(slot, frozen({ outcome: 'cancelled' as const, reason })); } +function signalAborted(signal: AbortSignal): boolean | undefined { + if (typeof abortSignalAbortedGetter !== 'function') return undefined; + try { + return reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) as boolean; + } catch { + return undefined; + } +} + +function addAbortListener(signal: AbortSignal, listener: () => void): boolean { + if (typeof abortSignalAddEventListener !== 'function') return false; + try { + reflectApplyIntrinsic(abortSignalAddEventListener, signal, ['abort', listener, { once: true }]); + return true; + } catch { + return false; + } +} + +function removeAbortListener(signal: AbortSignal, listener: () => void): void { + if (typeof abortSignalRemoveEventListener !== 'function') return; + try { + reflectApplyIntrinsic(abortSignalRemoveEventListener, signal, ['abort', listener]); + } catch { + // Logical cancellation authority is already detached. + } +} + function responseMembershipIsExact( parsed: ParsedAuctionBatchResponse, slots: readonly string[] @@ -207,11 +252,7 @@ export function createAuctionBatchService( const cleanupSignal = (): void => { if (!callerListener || !input.signal) return; - try { - input.signal.removeEventListener('abort', callerListener); - } catch { - // A hostile signal cannot retain batch authority. - } + removeAbortListener(input.signal, callerListener); callerListener = undefined; }; @@ -382,19 +423,17 @@ export function createAuctionBatchService( finishIfComplete(); return publicBatch; } - if (input.signal?.aborted === true) { - cancelLive('caller_aborted'); - return publicBatch; - } if (input.signal) { + if (signalAborted(input.signal) !== false) { + cancelLive('caller_aborted'); + return publicBatch; + } callerListener = (): void => cancelLive('caller_aborted'); - try { - input.signal.addEventListener('abort', callerListener, { once: true }); - } catch { + if (!addAbortListener(input.signal, callerListener)) { cancelLive('caller_aborted'); return publicBatch; } - if (Reflect.get(input.signal, 'aborted') === true) { + if (signalAborted(input.signal) !== false) { cancelLive('caller_aborted'); return publicBatch; } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 53fcfde7c..d0fd7f3a3 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -371,12 +371,18 @@ function resolveUnique( } function validSlotIdentity(value: string): boolean { - return ( - value.length > 0 && - new TextEncoder().encode(value).length <= 256 && - !/[\p{Cc}]/u.test(value) && - !/[\uD800-\uDFFF]/u.test(value) - ); + if ( + value.length === 0 || + new TextEncoder().encode(value).length > 256 || + /[\uD800-\uDFFF]/u.test(value) + ) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; } function frozenAliases(aliases: readonly string[] | undefined): readonly string[] | undefined { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 97bfcc5c5..3ea863325 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -834,18 +834,57 @@ describe('browser composition', () => { }; requestBodies.push(body); const slots = body.adUnits.map(({ code }) => code); + const winnerSlot = + requestBodies.length === 1 || (slots.length === 1 && slots[0] === 'ambiguous-slot') + ? slots[0] + : undefined; + const candidateId = 'AAAAAAAAAAAA'; + const renderSource = { + type: 'adm', + version: 1, + adm: '
programmatic winner
', + width: 300, + height: 250, + } as const; return { ok: true, json: async () => ({ id: `auction-${requestBodies.length}`, cur: 'USD', - seatbid: [], + seatbid: winnerSlot + ? [ + { + seat: 'fictional', + bid: [ + { + id: 'r1_AAAAAAAAAAAAAAAAAAAAAA', + impid: winnerSlot, + price: 1, + adm: renderSource.adm, + w: renderSource.width, + h: renderSource.height, + ext: { + trusted_server: { + candidate_id: candidateId, + slot_id: winnerSlot, + render_source: renderSource, + }, + }, + }, + ], + }, + ] + : [], ext: { trusted_server: { slot_results: { version: 1, auctionId: `auction-${requestBodies.length}`, - results: slots.map((slot) => ({ slot, outcome: 'no_bid' })), + results: slots.map((slot) => + slot === winnerSlot + ? { slot, outcome: 'winner', candidateId } + : { slot, outcome: 'no_bid' } + ), }, }, }, @@ -917,6 +956,13 @@ describe('browser composition', () => { expect(api.addAdUnits(programmatic)).toEqual({ registered: ['programmatic-slot'] }); expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); + const slotService = composition.slotServiceForTest(); + expect(slotService?.resolveRegisteredSlot('programmatic-slot')).toMatchObject({ + domAliases: [], + registeredSlotId: 'programmatic-slot', + source: 'programmatic', + }); + expect(slotService?.resolveDomAlias('programmatic-slot')).toBeUndefined(); expect(() => api.addAdUnits([ { @@ -930,10 +976,18 @@ describe('browser composition', () => { ]) ).toThrowError(expect.objectContaining({ code: 'slot_collision', unitIndex: 1 })); expect(composition.projectionSlotsForTest()).toEqual(['server-slot', 'programmatic-slot']); - await expect(api.requestAds({ slots: ['unknown', 'programmatic-slot'] })).resolves.toEqual({ + document.body.innerHTML = '
placeholder
'; + const explicit = api.requestAds({ slots: ['unknown', 'programmatic-slot'] }); + await vi.waitFor(() => + expect(document.querySelector('#programmatic-slot iframe')).not.toBeNull() + ); + const frame = document.querySelector('#programmatic-slot iframe'); + expect(frame?.srcdoc).toContain('programmatic winner'); + frame?.dispatchEvent(new Event('load')); + await expect(explicit).resolves.toEqual({ slots: [ { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, - { slot: 'programmatic-slot', path: 'primary', outcome: 'no_bid' }, + { slot: 'programmatic-slot', path: 'primary', outcome: 'accepted' }, ], }); expect(requestBodies[0]).toEqual({ @@ -966,6 +1020,55 @@ describe('browser composition', () => { expect(contextContributor).toHaveBeenCalledTimes(2); expect(auctionFetcher).toHaveBeenCalledTimes(2); + expect( + slotService?.register(session!.currentNavigation!, [ + { + adUnitCode: '/network/path', + domAliases: ['publisher-alias'], + registeredSlotId: 'alias-owner', + source: 'server', + }, + ]) + ).toMatchObject({ ok: true }); + await expect( + api.requestAds({ slots: ['publisher-alias', '/network/path', 'alias-owner'] }) + ).resolves.toEqual({ + slots: [ + { slot: 'publisher-alias', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: '/network/path', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, + { slot: 'alias-owner', path: 'primary', outcome: 'no_bid' }, + ], + }); + expect(requestBodies[2]?.adUnits.map(({ code }) => code)).toEqual(['alias-owner']); + + expect( + api.addAdUnits({ + code: 'ambiguous-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }) + ).toEqual({ registered: ['ambiguous-slot'] }); + document.body.insertAdjacentHTML( + 'beforeend', + '
' + ); + await expect(api.requestAds({ slots: ['ambiguous-slot'] })).resolves.toEqual({ + slots: [ + { + slot: 'ambiguous-slot', + path: 'primary', + outcome: 'failed', + reason: 'slot_unresolved', + }, + ], + }); + expect(document.querySelectorAll('[id="ambiguous-slot"] iframe')).toHaveLength(0); + expect(contextContributor).toHaveBeenCalledTimes(4); + expect(auctionFetcher).toHaveBeenCalledTimes(4); + composition.runtime.dispose(); + expect(() => api.addAdUnits(programmatic)).toThrowError( + expect.objectContaining({ name: 'AdUnitRegistrationError', code: 'slot_collision' }) + ); + document.body.innerHTML = ''; }); }); diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index cc0fe6283..d94e463a0 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -157,10 +157,39 @@ describe('registry', () => { ) ).toHaveLength(1); } + for (const existingCount of [254, 255]) { + const existing = new Set( + Array.from({ length: existingCount }, (_, index) => `server-${index}`) + ); + expect(prepareProgrammaticAdUnits(unit(`at-${existingCount + 1}`), existing)).toHaveLength(1); + } const existing = new Set(Array.from({ length: 256 }, (_, index) => `server-${index}`)); expectRegistrationError( () => prepareProgrammaticAdUnits(unit('overflow'), existing), 'registry_capacity' ); }); + + it('enforces the encoded auction-unit body cap at the exact byte boundary', () => { + const candidate = { + code: 'body-boundary', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'fictional', params: { payload: '' } }], + }; + const baseBytes = new TextEncoder().encode( + JSON.stringify({ adUnits: [candidate], config: {} }) + ).byteLength; + const payloadAtLimit = 'x'.repeat(256 * 1024 - baseBytes); + candidate.bids[0]!.params.payload = payloadAtLimit; + expect( + new TextEncoder().encode(JSON.stringify({ adUnits: [candidate], config: {} })) + ).toHaveLength(256 * 1024); + expect(prepareProgrammaticAdUnits(candidate, new Set())).toHaveLength(1); + + candidate.bids[0]!.params.payload += 'x'; + expectRegistrationError( + () => prepareProgrammaticAdUnits(candidate, new Set()), + 'request_body_too_large' + ); + }); }); diff --git a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts index 617a10e2d..48d935289 100644 --- a/crates/trusted-server-js/lib/test/services/auction_batch.test.ts +++ b/crates/trusted-server-js/lib/test/services/auction_batch.test.ts @@ -258,6 +258,35 @@ describe('auction batch service', () => { } }); + it('cancels issued children without fetching for an already-aborted caller', async () => { + const fetcher = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); + const createAttempt = vi.fn((owner: RenderAttemptScope) => ({ + ok: true as const, + value: attemptHarness(owner).attempt, + })); + const service = createService({ + createAttempt, + fetcher, + renderWinner: () => false, + }); + const caller = new AbortController(); + caller.abort(); + + await expect( + service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }).result + ).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(createAttempt).toHaveBeenCalledOnce(); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('supersedes only overlapping children and retains the old fetch until all old children settle', async () => { const firstFetch = abortablePendingFetcher(); const secondFetch = successfulFetcher(response([{ slot: 'slot-a', outcome: 'no_bid' }])); @@ -594,4 +623,37 @@ describe('auction batch service', () => { }); expect(pending.signals[0]?.aborted).toBe(true); }); + + it('observes a branded caller signal without consulting shadowed instance hooks', async () => { + const pending = abortablePendingFetcher(); + const service = createService({ + createAttempt: (owner) => ({ ok: true, value: attemptHarness(owner).attempt }), + fetcher: pending.fetcher, + renderWinner: () => false, + }); + const caller = new AbortController(); + const publisherHook = vi.fn(() => { + throw new Error('publisher signal hook'); + }); + Object.defineProperties(caller.signal, { + aborted: { configurable: true, get: publisherHook }, + addEventListener: { configurable: true, get: publisherHook }, + removeEventListener: { configurable: true, get: publisherHook }, + }); + + const batch = service.create({ + navigation: navigation(), + requestBody: '{}', + signal: caller.signal, + slots: Object.freeze(['slot-a']), + timeoutMs: 10_000, + }); + caller.abort(); + + await expect(batch.result).resolves.toEqual({ + slots: [{ slot: 'slot-a', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + expect(publisherHook).not.toHaveBeenCalled(); + expect(pending.signals[0]?.aborted).toBe(true); + }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 7c507c50b..5c859c2f6 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -211,7 +211,7 @@ function bindTrustedSlot(service: SlotService, navigation: NavigationSession, id describe('slot registry', () => { afterEach(() => vi.useRealTimers()); - it('accepts exact nonempty 256-byte ids and rejects empty, 257-byte, NUL, and controls', () => { + it('accepts exact nonempty 256-byte ids and rejects empty, 257-byte, and ASCII controls', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); const valid = `${'a'.repeat(254)}é`; @@ -224,13 +224,17 @@ describe('slot registry', () => { 'a'.repeat(257), 'nul\0id', 'line\nid', - `c1${String.fromCharCode(0x85)}`, + `del${String.fromCharCode(0x7f)}id`, ]) { expect(service.register(navigation, [serverRegistration(invalid)])).toEqual({ ok: false, reason: 'invalid_slot_id', }); } + + expect( + service.register(navigation, [serverRegistration(`c1${String.fromCharCode(0x85)}id`)]) + ).toMatchObject({ ok: true }); }); it('reserves the combined 256-record capacity atomically', () => { From 03f37250af20d832502738edad91faef789f18c2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:05:17 -0700 Subject: [PATCH 311/494] Account for the direct auction request envelope --- crates/trusted-server-js/lib/src/core/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index f2979166b..6e2c8b46e 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -422,8 +422,8 @@ export function prepareProgrammaticAdUnits( const unitsBytes = measureJsonBytes(prepared); if (unitsBytes === undefined) throw new AdUnitRegistrationError('invalid_params'); - // `{"adUnits":` + encoded array + `}`. - if (boundedBytes(12, unitsBytes) > MAX_AUCTION_BODY_BYTES) { + // `{"adUnits":` + encoded array + `,"config":{}}`. + if (boundedBytes(24, unitsBytes) > MAX_AUCTION_BODY_BYTES) { throw new AdUnitRegistrationError('request_body_too_large'); } if (occupied.size + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { From fce5781fce0b88f4452bd110305b0a879faf612e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:06:55 -0700 Subject: [PATCH 312/494] Serialize direct auction bodies without publisher hooks --- .../lib/src/composition/browser.ts | 8 +- .../lib/src/core/registry.ts | 98 +++++++++++++++++++ .../lib/test/core/registry.test.ts | 32 +++++- 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index a99a80e22..f60b9905b 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -29,6 +29,7 @@ import { AdUnitRegistrationError, addAdUnitsResult, prepareProgrammaticAdUnits, + serializeAuctionRequestBody, } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; @@ -362,10 +363,9 @@ export function createTestBrowserRuntimeComposition( ); let requestBody: string; try { - requestBody = JSON.stringify({ adUnits, config: context }); - if (new TextEncoder().encode(requestBody).byteLength > 256 * 1024) { - throw new Error('auction request body exceeds limit'); - } + const serialized = serializeAuctionRequestBody(adUnits, context); + if (!serialized) throw new Error('auction request body exceeds limit'); + requestBody = serialized; } catch { return Promise.resolve( combineRequestResults( diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 6e2c8b46e..0e701b973 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -9,6 +9,11 @@ const MAX_PROGRAMMATIC_UNITS = 256; const MAX_ACTIVE_SLOT_RECORDS = 256; const MAX_JSON_STRUCTURE_ENTRIES = Math.floor((MAX_AUCTION_BODY_BYTES - 1) / 2); const textEncoder = new TextEncoder(); +const reflectApplyIntrinsic = Reflect.apply; +const jsonStringifyIntrinsic = JSON.stringify; +const objectCreateIntrinsic = Object.create; +const objectSetPrototypeOfIntrinsic = Object.setPrototypeOf; +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; export type AdUnitRegistrationErrorCode = | 'invalid_units' @@ -201,6 +206,80 @@ function copyJsonRecord(value: unknown): Readonly> | und } } +function safeSerializationContainer(array: boolean): Record | unknown[] { + if (!array) { + return reflectApplyIntrinsic(objectCreateIntrinsic, Object, [null]) as Record; + } + const output: unknown[] = []; + reflectApplyIntrinsic(objectSetPrototypeOfIntrinsic, Object, [output, null]); + return output; +} + +/** Copy accepted JSON data onto containers that inherit no publisher hooks. */ +function copyJsonForSerialization(value: object): object | undefined { + const rootSnapshot = snapshotJsonContainer(value); + if (!rootSnapshot) return undefined; + const root = safeSerializationContainer(rootSnapshot.array); + const active = new Set([value]); + const completed = new WeakMap | unknown[]>(); + const stack: JsonCloneFrame[] = [ + { index: 0, output: root, snapshot: rootSnapshot, source: value }, + ]; + let structureEntries = 1; + try { + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (!frame) return undefined; + if (frame.index >= frame.snapshot.entries.length) { + completed.set(frame.source, frame.output); + active.delete(frame.source); + stack.pop(); + continue; + } + const entry = frame.snapshot.entries[frame.index]; + frame.index += 1; + if (!entry || ++structureEntries > MAX_JSON_STRUCTURE_ENTRIES) return undefined; + const primitive = jsonPrimitive(entry.value); + if (primitive !== undefined || entry.value === null) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: primitive, + writable: true, + }); + continue; + } + if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { + return undefined; + } + const completedChild = completed.get(entry.value); + if (completedChild) { + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: completedChild, + writable: true, + }); + continue; + } + const childSnapshot = snapshotJsonContainer(entry.value); + if (!childSnapshot) return undefined; + const child = safeSerializationContainer(childSnapshot.array); + Object.defineProperty(frame.output, entry.key, { + configurable: true, + enumerable: true, + value: child, + writable: true, + }); + active.add(entry.value); + stack.push({ index: 0, output: child, snapshot: childSnapshot, source: entry.value }); + } + return root; + } catch { + return undefined; + } +} + function encodedJsonStringBytes(value: string): number { let bytes = 2; for (let index = 0; index < value.length; index += 1) { @@ -436,6 +515,25 @@ export function addAdUnitsResult(units: readonly ProgrammaticAdUnit[]): AddAdUni return Object.freeze({ registered: Object.freeze(units.map(({ code }) => code)) }); } +/** Serialize one bounded `/auction` body without consulting inherited `toJSON` hooks. */ +export function serializeAuctionRequestBody( + adUnits: readonly Readonly[], + config: Readonly> +): string | undefined { + try { + const detached = copyJsonForSerialization({ adUnits, config }); + if (!detached) return undefined; + const serialized = reflectApplyIntrinsic(jsonStringifyIntrinsic, JSON, [detached]) as unknown; + if (typeof serialized !== 'string') return undefined; + const bytes = reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [ + serialized, + ]) as Uint8Array; + return bytes.byteLength <= MAX_AUCTION_BODY_BYTES ? serialized : undefined; + } catch { + return undefined; + } +} + // The mutable merge registry remains connected only to the pre-cutover core entry. const legacyRegistry = new Map(); diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index d94e463a0..ff44b062e 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { AdUnit } from '../../src/core/types'; -import { AdUnitRegistrationError, prepareProgrammaticAdUnits } from '../../src/core/registry'; +import { + AdUnitRegistrationError, + prepareProgrammaticAdUnits, + serializeAuctionRequestBody, +} from '../../src/core/registry'; function unit(code = 'programmatic-slot'): Record { return { @@ -192,4 +196,30 @@ describe('registry', () => { 'request_body_too_large' ); }); + + it('serializes detached auction data without invoking inherited toJSON hooks', () => { + const prepared = prepareProgrammaticAdUnits(unit(), new Set()); + const context = Object.freeze({ segments: Object.freeze(['one']) }); + const publisherHook = vi.fn(() => { + throw new Error('publisher toJSON hook'); + }); + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value: publisherHook, + }); + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: publisherHook, + }); + let body: string | undefined; + try { + body = serializeAuctionRequestBody(prepared, context); + } finally { + Reflect.deleteProperty(Object.prototype, 'toJSON'); + Reflect.deleteProperty(Array.prototype, 'toJSON'); + } + + expect(publisherHook).not.toHaveBeenCalled(); + expect(body).toBe(JSON.stringify({ adUnits: prepared, config: context })); + }); }); From 691fdd06b2c2ee556200afff743febbfaf9f9fd3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:13:06 -0700 Subject: [PATCH 313/494] Prepare the real TSJS performance marks --- crates/trusted-server-core/src/publisher.rs | 28 +++++- .../lib/src/adapters/googletag.ts | 44 ++++++++- .../lib/test/adapters/googletag.test.ts | 94 +++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 54dc8bd22..778addf82 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3792,6 +3792,18 @@ else t.bids=b;\ ) } +/// Prospective hard-cutover mark emitted at the bids/projection boundary. +/// +/// Task 19 inserts this already-tested fragment into the production boot path in +/// the same atomic switch that installs the matching first-display mark. +#[allow( + dead_code, + reason = "Task 16 prepares this fragment for the atomic Task 19 production switch" +)] +pub(crate) fn build_bids_script_performance_mark() -> &'static str { + "(function(){try{window.performance.mark(\"tsjs:bids-script\");}catch(_){}})();" +} + /// Build the empty-bids `'); + + expect(nativeWrite).toHaveBeenCalledTimes(1); + expect(nativeWrite.mock.calls[0]?.[0]).toContain('/proxy/runtime.js'); + + guard.reset(); + expect(document.write).toBe(nativeWrite); + expect(installedWrite).not.toBe(nativeWrite); + }); + + it('removes fallback instance src descriptors during reset', () => { + const nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const descriptorSpy = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation( + (target: object, property: PropertyKey): PropertyDescriptor | undefined => { + if (target === HTMLScriptElement.prototype && property === 'src') return undefined; + return nativeGetOwnPropertyDescriptor(target, property); + } + ); + const guard = createScriptGuard({ + deepInterception: { documentWriteUrlHint: 'sdk.example' }, + id: 'shared-layered-instance-test', + isTargetUrl: (url) => new URL(url, window.location.href).hostname === 'sdk.example', + rewriteUrl: (url) => { + const parsed = new URL(url, window.location.href); + return `${window.location.origin}/proxy${parsed.pathname}`; + }, + }); + guards.push(guard); + + try { + guard.install(); + const script = document.createElement('script'); + script.src = 'https://sdk.example/first.js'; + expect(script.src).toContain('/proxy/first.js'); + + guard.reset(); + script.src = 'https://sdk.example/after-reset.js'; + expect(script.src).toBe('https://sdk.example/after-reset.js'); + } finally { + descriptorSpy.mockRestore(); + } + }); +}); From a9718421b4a0bfcf0052c4cebe6d012aa3565011 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:37 -0700 Subject: [PATCH 320/494] Wire attributable GPT empty fallback --- .../lib/src/composition/browser.ts | 27 +- .../lib/src/integrations/gpt/module.ts | 136 ++++++++++ .../lib/test/composition/browser.test.ts | 219 +++++++++++++++- .../lib/test/integrations/gpt/module.test.ts | 247 +++++++++++++++++- 4 files changed, 626 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index ee1e33424..705d52282 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -34,6 +34,7 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; +import { startGptSlotOperation, type GptSlotOperationInput } from '../integrations/gpt/module'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -62,9 +63,11 @@ import { type RenderAttempt, type CommittedArtifactStore, type RendererNonceRegistry, + type SlotOperationCreationResult, } from '../services/render'; import { createPucBridge, type PucBridge, type PucBridgeOptions } from '../services/puc_bridge'; import { + createBrowserSlotReconciliationBoundary, createSlotService, type SlotRecord, type SlotRegistrationFailure, @@ -123,6 +126,10 @@ export interface BrowserRuntimeComposition extends BrowserComposition { readonly rendererNonceRegistryForTest: () => RendererNonceRegistry | undefined; /** Return the single runtime-owned PUC bridge only in coordinated-cutover tests. */ readonly pucBridgeForTest: () => PucBridge | undefined; + /** Join one prospective GPT attempt through the runtime-owned services in tests. */ + readonly startGptSlotOperationForTest: ( + input: Omit + ) => SlotOperationCreationResult; } export interface BrowserCoreActivations { @@ -450,7 +457,14 @@ export function createTestBrowserRuntimeComposition( parseProjection ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); - const slotService = createSlotService({ googletag: composition.adapters.googletag }); + const reconciliation = + typeof document === 'undefined' || typeof MutationObserver === 'undefined' + ? undefined + : createBrowserSlotReconciliationBoundary(document, MutationObserver); + const slotService = createSlotService({ + googletag: composition.adapters.googletag, + ...(reconciliation ? { reconciliation } : {}), + }); const targetingService = createTargetingService(); const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), @@ -717,5 +731,16 @@ export function createTestBrowserRuntimeComposition( reservationServiceForTest: () => browserServices?.reservations, rendererNonceRegistryForTest: () => browserServices?.rendererNonces, pucBridgeForTest: () => browserServices?.pucBridge, + startGptSlotOperationForTest: ( + input: Omit + ): SlotOperationCreationResult => { + const services = browserServices; + if (!services) return Object.freeze({ ok: false, reason: 'invalid_attempt' }); + return startGptSlotOperation({ + ...input, + pucBridge: services.pucBridge, + slots: services.slots, + }); + }, }); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 7b5392b8e..e709c8003 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -3,6 +3,14 @@ import type { IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; +import { + createSlotOperation, + type RenderAttempt, + type SlotOperationCreationResult, + type SlotOperationOptions, +} from '../../services/render'; +import type { PucBridge, PucGamAttemptInput } from '../../services/puc_bridge'; +import type { SlotRequestOutcome, SlotService } from '../../services/slots'; import { installGptGuard, resetGuardState } from './script_guard'; @@ -23,6 +31,134 @@ interface GptIntegrationRuntime { readonly start: (config: unknown) => void; } +export interface GptSlotOperationInput extends Omit { + readonly attempt: RenderAttempt; + readonly createFallback?: SlotOperationOptions['createFallback']; + readonly operation: 'display' | 'refresh'; + readonly pucBridge: Pick; + readonly requestClass: string; + readonly slots: Pick; +} + +function settleFromSlotOutcome( + attempt: RenderAttempt, + bridge: GptSlotOperationInput['pucBridge'], + bridgeInput: PucGamAttemptInput, + outcome: SlotRequestOutcome +): void { + try { + if (outcome.status === 'empty') { + attempt.fail('gam_empty'); + return; + } + if (outcome.status === 'rendered') { + if (!bridge.recordNonemptyGam(bridgeInput)) attempt.fail('cycle_unattributable'); + return; + } + if (outcome.status === 'failed') { + attempt.fail(outcome.reason); + return; + } + if (outcome.status === 'cancelled') attempt.cancel(outcome.reason); + } catch { + try { + attempt.fail('internal_error'); + } catch { + // The attempt latch remains the terminal authority. + } + } +} + +/** + * Join one TS-owned physical GPT cycle to its primary render attempt. + * + * Only the slot service may identify an attributable empty cycle. The resulting + * `gam_empty` transition is therefore the sole path that can activate the + * optional `SlotOperation` fallback child. + */ +export function startGptSlotOperation(input: GptSlotOperationInput): SlotOperationCreationResult { + const operation = createSlotOperation({ + primary: input.attempt, + ...(input.createFallback === undefined ? {} : { createFallback: input.createFallback }), + }); + if (!operation.ok) return operation; + + const bridgeInput = Object.freeze({ + artifact: input.artifact, + attempt: input.attempt, + owner: input.owner, + reservationId: input.reservationId, + }); + const registered = (() => { + try { + return input.pucBridge.registerGamAttempt(bridgeInput); + } catch { + return false; + } + })(); + if (!registered) { + try { + input.attempt.fail('gpt_request_failed'); + } catch { + // The operation still observes any terminal result already committed by the bridge. + } + return operation; + } + + let handle: ReturnType; + try { + handle = input.slots.request({ + intentId: input.attempt.id, + navigationGeneration: input.attempt.navigationGeneration, + operation: input.operation, + registeredSlotId: input.attempt.slot, + requestClass: input.requestClass, + }); + } catch { + input.attempt.fail('gpt_request_failed'); + return operation; + } + + let handleDisposed = false; + const disposeHandle = (): void => { + if (handleDisposed) return; + handleDisposed = true; + try { + handle.dispose(); + } catch { + // Attempt settlement remains authoritative when request cleanup throws. + } + }; + const observing = (() => { + try { + return input.attempt.onSettled(disposeHandle); + } catch { + return false; + } + })(); + if (!observing) { + disposeHandle(); + try { + input.attempt.fail('internal_error'); + } catch { + // A concurrently terminal attempt cannot be overwritten. + } + return operation; + } + + void handle.result.then( + (outcome) => settleFromSlotOutcome(input.attempt, input.pucBridge, bridgeInput, outcome), + () => { + try { + input.attempt.fail('gpt_request_failed'); + } catch { + // A late rejected request cannot overwrite an existing terminal outcome. + } + } + ); + return operation; +} + function validFrozenConfig(candidate: unknown): boolean { const seen = new Set(); let nodes = 0; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 792bbd71b..fd7746ae5 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -26,7 +26,11 @@ import { createGptIntegrationRegistration } from '../../src/integrations/gpt/mod import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; -import type { RenderAttempt } from '../../src/services/render'; +import { + createRenderAttempt, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../src/services/render'; function createTarget() { return { @@ -45,6 +49,52 @@ function fakeGoogletagAdapter( return Object.freeze({ ...createNoopGoogletagAdapter(), bindingStatus }); } +function synchronousGptAdapter() { + const listeners = new Map void>>(); + const bindingToken = Object.freeze({}); + const refresh = vi.fn(); + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => bindingToken, + clearTargeting: vi.fn(), + display: vi.fn(), + getTargeting: vi.fn(() => []), + observeTargeting: () => vi.fn(), + refresh, + serviceState: () => + Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), + setTargeting: vi.fn(), + slots: () => Object.freeze([]), + subscribe: (eventType: string, listener: (event: unknown) => void) => { + const registered = listeners.get(eventType) ?? new Set(); + registered.add(listener); + listeners.set(eventType, registered); + return () => registered.delete(listener); + }, + transactionalReplace: () => Object.freeze({ status: 'destroyed' as const }), + }); + const adapter: GoogletagAdapter = Object.freeze({ + bindingStatus: () => 'present', + dispose: vi.fn(), + notifyReady: vi.fn(), + run: (command: (gpt: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + return { + adapter, + emit: (eventType: string, event: unknown): void => { + for (const listener of listeners.get(eventType) ?? []) listener(event); + }, + refresh, + }; +} + function fakePrebidAdapter( bindingStatus: () => PrebidBindingStatus = () => 'pending' ): PrebidAdapter { @@ -119,6 +169,173 @@ describe('browser composition', () => { expect(display).toHaveBeenCalledTimes(3); }); + it('routes an attributable empty GPT cycle through the owned slot and PUC services', async () => { + const gpt = synchronousGptAdapter(); + let prefix = 0; + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([Object.freeze({ slot: 'slot-one', outcome: 'no_bid' as const })]), + }), + bids: Object.freeze([]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId: 'a'.repeat(64), + manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, + knownIntegrationIds: Object.freeze([]), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + } + ); + expect(composition.runtime.start()).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const session = composition.runtimeSessionForTest(); + const navigation = session?.currentNavigation; + const batch = navigation?.createAuctionBatch('gpt-primary'); + const services = session?.interfaces; + const artifacts = services?.['artifacts']; + const reservations = composition.reservationServiceForTest(); + const slots = composition.slotServiceForTest(); + if (!navigation || !batch || !artifacts || !reservations || !slots) { + throw new Error('Expected runtime-owned GPT dependencies'); + } + const createAttempt = (parentAttemptId?: string): RenderAttempt => { + const owner = batch.createRenderAttempt('slot-one'); + if (!owner.ok) throw new Error(owner.reason); + const attempt = createRenderAttempt({ + artifacts: artifacts as Parameters[0]['artifacts'], + owner: owner.value, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm' | 'cache'; version: 1 }>) + : undefined, + reservations, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), + }); + if (!attempt.ok) throw new Error(attempt.reason); + return attempt.value; + }; + const ownerResult = batch.createRenderAttempt('slot-one'); + if (!ownerResult.ok) throw new Error(ownerResult.reason); + const source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
fictional fallback
', + width: 300, + height: 250, + }); + const primaryResult = createRenderAttempt({ + artifacts: artifacts as Parameters[0]['artifacts'], + owner: ownerResult.value, + prepareRenderSource: () => source, + reservations, + }); + if (!primaryResult.ok) throw new Error(primaryResult.reason); + const primary = primaryResult.value; + const reservationId = `r1_${'a'.repeat(22)}`; + const winnerContext = Object.freeze({ selectedCpm: 1 }); + expect( + reservations.registerRender({ + reservationId, + slot: primary.slot, + navigation, + attemptId: primary.id, + renderSource: source, + winnerContext, + }) + ).toMatchObject({ ok: true }); + const physicalSlot = Object.freeze({}); + const slotElement = document.createElement('div'); + slotElement.id = 'slot-one'; + document.body.append(slotElement); + expect( + slots.adoptGptSlot(navigation.generation, 'slot-one', { + definition: { + adUnitPath: '/123/slot-one', + elementId: 'slot-one', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: primary.id, + slot: primary.slot, + navigationGeneration: primary.navigationGeneration, + dispose: vi.fn(), + }) satisfies CommittedRenderArtifact; + let fallback: RenderAttempt | undefined; + const operation = composition.startGptSlotOperationForTest({ + artifact, + attempt: primary, + createFallback: (parentAttemptId) => { + fallback = createAttempt(parentAttemptId); + return Object.freeze({ ok: true as const, value: fallback }); + }, + operation: 'refresh', + owner: ownerResult.value, + requestClass: 'primary', + reservationId, + }); + expect(operation.ok).toBe(true); + + await Promise.resolve(); + await Promise.resolve(); + expect(gpt.refresh).toHaveBeenCalledExactlyOnceWith( + [physicalSlot], + Object.freeze({ changeCorrelator: false }) + ); + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'response-one', + slot: physicalSlot, + }); + await Promise.resolve(); + + expect(primary.snapshot().outcome).toEqual({ outcome: 'failed', reason: 'gam_empty' }); + expect(fallback).toBeDefined(); + fallback?.fail('winner_not_renderable'); + expect(operation.ok && operation.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallback: { outcome: 'failed', reason: 'winner_not_renderable' }, + }, + }); + composition.runtime.dispose(); + slotElement.remove(); + }); + it('derives exact APS validation coordinates only for the real browser target', () => { const renderer = { type: 'aps', diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 623451d42..234bd74ad 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -1,14 +1,106 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createGptIntegrationRegistration } from '../../../src/integrations/gpt/module'; +import { + createGptIntegrationRegistration, + startGptSlotOperation, + type GptSlotOperationInput, +} from '../../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../../src/integrations/gpt/script_guard'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; import { createIntegrationRegistry, type IntegrationInstallCallbacks, type IntegrationRegistration, } from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; +import { createReservationService } from '../../../src/services/reservations'; +import type { SlotRequestOutcome } from '../../../src/services/slots'; const RELEASE_ID = 'a'.repeat(64); +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + +function createAttemptHarness() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation creation'); + const batch = navigationResult.value.createAuctionBatch('gpt-cycle'); + if (!batch) throw new Error('Expected batch creation'); + const artifacts = createCommittedArtifactStore(); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && + candidate !== null && + Object.isFrozen(candidate) && + 'type' in candidate && + 'version' in candidate + ? (candidate as Readonly<{ type: 'aps' | 'adm' | 'cache'; version: 1 }>) + : undefined, + }); + const createAttemptWithOwner = (parentAttemptId?: string) => { + const owner = batch.createRenderAttempt('slot-one'); + if (!owner.ok) throw new Error(`Expected attempt owner: ${owner.reason}`); + const created = createRenderAttempt({ + artifacts, + owner: owner.value, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && + candidate !== null && + Object.isFrozen(candidate) && + 'type' in candidate && + 'version' in candidate + ? (candidate as Readonly<{ type: 'aps' | 'adm' | 'cache'; version: 1 }>) + : undefined, + reservations, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), + }); + if (!created.ok) throw new Error(`Expected render attempt: ${created.reason}`); + return { attempt: created.value, owner: owner.value }; + }; + const primaryCreated = createAttemptWithOwner(); + const primary = primaryCreated.attempt; + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: primary.id, + slot: primary.slot, + navigationGeneration: primary.navigationGeneration, + dispose: vi.fn(), + }) satisfies CommittedRenderArtifact; + return { + artifact, + createAttempt: (parentAttemptId: string): RenderAttempt => + createAttemptWithOwner(parentAttemptId).attempt, + primary, + primaryOwner: primaryCreated.owner, + runtime, + }; +} + +function deferredSlotOutcome() { + let resolve!: (outcome: SlotRequestOutcome) => void; + const result = new Promise((resolveResult) => { + resolve = resolveResult; + }); + const dispose = vi.fn(); + return { + dispose, + request: vi.fn(() => Object.freeze({ status: 'active' as const, result, dispose })), + resolve, + }; +} function manifest(ids: readonly string[]) { return { @@ -206,4 +298,157 @@ describe('transactional GPT integration module', () => { expect(runtimeFailures).toEqual([{ id: 'gpt', phase: 'after_commit' }]); expect(isGuardInstalled()).toBe(false); }); + + it('starts fallback only after an attributable TS-owned empty cycle settles the primary', async () => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const order: string[] = []; + let fallback: RenderAttempt | undefined; + const bridgeInput: unknown[] = []; + const bridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + bridgeInput.push(input); + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn(() => true), + }; + const started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + createFallback: (parentAttemptId) => { + expect(harness.primary.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'gam_empty', + }); + order.push('fallback:create'); + fallback = harness.createAttempt(parentAttemptId); + return Object.freeze({ ok: true as const, value: fallback }); + }, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge: bridge, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }); + + expect(started.ok).toBe(true); + expect(bridge.registerGamAttempt).toHaveBeenCalledTimes(1); + expect(slot.request).toHaveBeenCalledWith({ + intentId: harness.primary.id, + navigationGeneration: harness.primary.navigationGeneration, + operation: 'refresh', + registeredSlotId: harness.primary.slot, + requestClass: 'primary', + }); + + slot.resolve(Object.freeze({ status: 'empty', responseIdentifier: 'response-one' })); + await Promise.resolve(); + + expect(order).toEqual(['fallback:create']); + expect(harness.primary.snapshot()).toMatchObject({ + state: 'failed', + outcome: { outcome: 'failed', reason: 'gam_empty' }, + }); + expect(started.ok && started.value.snapshot()).toEqual({ settled: false }); + expect(slot.dispose).toHaveBeenCalledTimes(1); + expect(bridge.recordNonemptyGam).not.toHaveBeenCalled(); + + expect(fallback?.fail('gpt_request_failed')).toBe(true); + expect(started.ok && started.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'fallback', + primaryAttemptId: harness.primary.id, + primary: { outcome: 'failed', reason: 'gam_empty' }, + fallbackAttemptId: fallback?.id, + fallback: { outcome: 'failed', reason: 'gpt_request_failed' }, + }, + }); + expect(harness.primary.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'gam_empty', + }); + harness.runtime.dispose(); + }); + + it('joins an attributable nonempty cycle to the PUC bridge without settling the operation', async () => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const registered: unknown[] = []; + const nonempty: unknown[] = []; + const bridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + registered.push(input); + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn((input: unknown) => { + nonempty.push(input); + return true; + }), + }; + const input = { + artifact: harness.artifact, + attempt: harness.primary, + operation: 'display' as const, + owner: harness.primaryOwner, + pucBridge: bridge, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }; + const started = startGptSlotOperation(input); + slot.resolve(Object.freeze({ status: 'rendered', responseIdentifier: 'response-one' })); + await Promise.resolve(); + + expect(nonempty).toEqual(registered); + expect(started.ok && started.value.snapshot()).toEqual({ settled: false }); + expect(harness.primary.snapshot().state).toBe('waiting_for_gam_and_claim'); + expect(slot.dispose).not.toHaveBeenCalled(); + + harness.primary.cancel('superseded'); + expect(slot.dispose).toHaveBeenCalledTimes(1); + harness.runtime.dispose(); + }); + + it.each([ + [{ status: 'failed', reason: 'cycle_unattributable' }, 'cycle_unattributable'], + [{ status: 'failed', reason: 'slot_quarantined' }, 'slot_quarantined'], + [{ status: 'failed', reason: 'gpt_request_timeout' }, 'gpt_request_timeout'], + [{ status: 'failed', reason: 'gpt_completion_timeout' }, 'gpt_completion_timeout'], + [{ status: 'cancelled', reason: 'navigation_disposed' }, 'navigation_disposed'], + ] as const)( + 'does not start fallback for non-empty terminal cycle outcome %s', + async (slotOutcome, reason) => { + const harness = createAttemptHarness(); + const slot = deferredSlotOutcome(); + const createFallback = vi.fn(); + const started = startGptSlotOperation({ + artifact: harness.artifact, + attempt: harness.primary, + createFallback, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge: { + registerGamAttempt: (input) => input.attempt.beginGamClaim(), + recordNonemptyGam: () => true, + }, + requestClass: 'primary', + reservationId: RESERVATION_ID, + slots: { request: slot.request }, + }); + slot.resolve(Object.freeze(slotOutcome) as SlotRequestOutcome); + await Promise.resolve(); + + expect(createFallback).not.toHaveBeenCalled(); + expect(started.ok && started.value.snapshot()).toMatchObject({ + settled: true, + result: { + path: 'primary', + outcome: { reason }, + }, + }); + harness.runtime.dispose(); + } + ); }); From 3b5cf8157d53eb4eb38d5e837e94d532328bb523 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:51:19 -0700 Subject: [PATCH 321/494] Measure canonical auction projection bytes --- .../trusted-server-js/lib/src/core/auction.ts | 30 +- .../lib/src/services/slots.ts | 569 +++++++++++++++++- .../lib/test/core/auction.test.ts | 70 ++- .../lib/test/services/slots.test.ts | 380 ++++++++++++ 4 files changed, 1043 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index bfe0ce078..0fac2fd01 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -23,6 +23,7 @@ import type { ApsRendererV1, AuctionDecisionSetV1, BidRenderSourceV1, + BrowserAuctionProjectionV1, SlotAuctionDecisionV1, } from './types'; @@ -210,8 +211,7 @@ export function parseTrustedServerAuctionResponseV1( const winner = winners.find((entry) => entry.candidateId === bid.candidateId); return !winner || winner.slot !== bid.impid; }) || - winners.some((winner) => !bids.some((bid) => bid.candidateId === winner.candidateId)) || - jsonUtf8ByteLength(value) > MAX_BROWSER_AUCTION_PROJECTION_BYTES + winners.some((winner) => !bids.some((bid) => bid.candidateId === winner.candidateId)) ) { return undefined; } @@ -224,6 +224,32 @@ export function parseTrustedServerAuctionResponseV1( orderedBids.push(bid); } + const canonicalBids: BrowserAuctionProjectionV1['bids'] = []; + for (let index = 0; index < orderedBids.length; index += 1) { + const bid = orderedBids[index]; + if (!bid) return undefined; + canonicalBids.push({ + candidateId: bid.candidateId, + slot: bid.impid, + provider: bid.provider, + upstreamBidId: + bid.renderSource.type === 'aps' ? bid.renderSource.bidId : bid.rendererReservationId, + cpm: bid.price, + currency: 'USD', + targeting: {}, + rendererReservationId: bid.rendererReservationId, + renderSource: bid.renderSource, + }); + } + const canonicalProjection: BrowserAuctionProjectionV1 = { + version: 1, + auction, + bids: canonicalBids, + }; + if (jsonUtf8ByteLength(canonicalProjection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { + return undefined; + } + return { auction, bids: orderedBids }; } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index d0fd7f3a3..625c5ec54 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -19,6 +19,9 @@ export const MAX_ACTIVE_SLOT_RECORDS = 256; const GPT_REQUEST_START_TIMEOUT_MS = 3_000; const GPT_COMPLETION_TIMEOUT_MS = 10_000; +const GPT_RECONCILIATION_DEBOUNCE_MS = 250; +const GPT_RECONCILIATION_WINDOW_MS = 5_000; +const MAX_SUCCESSFUL_RECONCILIATIONS = 2; const MAX_PENDING_PUBLISHER_INTENTS = 64; const MAX_SLOT_ALIASES = 256; const MAX_PLACEMENT_QUARANTINE_KEYS = 2_048; @@ -79,6 +82,7 @@ export type SlotRequestFailure = | 'gpt_completion_timeout' | 'gpt_request_failed' | 'gpt_request_timeout' + | 'reconciliation_capacity' | 'slot_quarantined' | 'slot_unresolved'; @@ -151,11 +155,24 @@ export interface SlotService { export interface SlotServiceOptions { readonly googletag: GoogletagAdapter; readonly now?: () => number; + readonly reconciliation?: SlotReconciliationBoundary; +} + +export type SlotReconciliationResolution = + | Readonly<{ status: 'ambiguous' | 'unresolved' }> + | Readonly<{ status: 'unique'; element: object; elementId: string }>; + +/** Narrow DOM ownership boundary used by navigation-scoped slot reconciliation. */ +export interface SlotReconciliationBoundary { + readonly isConnected: (element: object) => boolean; + readonly observe: (callback: () => void) => () => void; + readonly resolve: (elementIds: readonly string[]) => SlotReconciliationResolution; } interface NavigationState { disposed: boolean; nextOrdinal: number; + observerRelease: (() => void) | undefined; readonly owner: NavigationSession; readonly records: Map; } @@ -164,6 +181,8 @@ interface InternalSlotRecord { activeIntent: RequestIntent | undefined; physical: PhysicalSlot | undefined; queuedIntent: RequestIntent | undefined; + reconciliation: ReconciliationWindow | undefined; + reconciliationSuccesses: number; readonly state: NavigationState; readonly view: SlotRecord; } @@ -178,6 +197,7 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; definition: GoogletagReplacementDefinition | undefined; + domElement: object | undefined; lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; placementKeys: readonly string[]; @@ -190,6 +210,16 @@ interface PhysicalSlot { destroyAttempted: boolean; } +interface ReconciliationWindow { + debounceTimer: ReturnType | undefined; + deadlineTimer: ReturnType | undefined; + readonly deadlineAt: number; + firstPassFinished: boolean; + operation: GoogletagOperation | undefined; + readonly orphan: PhysicalSlot; + terminal: boolean; +} + interface RequestIntent { completionTimer: ReturnType | undefined; readonly input: SlotRequestInput; @@ -501,6 +531,80 @@ function placementKeysFor( return Object.freeze(keys); } +/** Capture a browser DOM boundary without installing an observer until service activation. */ +export function createBrowserSlotReconciliationBoundary( + documentTarget: Document, + Observer: typeof MutationObserver +): SlotReconciliationBoundary | undefined { + try { + const root = documentTarget.documentElement; + const windowTarget = documentTarget.defaultView; + if (!root || !windowTarget || typeof Observer !== 'function') return undefined; + const querySelectorAll = windowTarget.Document.prototype.querySelectorAll; + const contains = windowTarget.Node.prototype.contains; + const elementId = Object.getOwnPropertyDescriptor(windowTarget.Element.prototype, 'id')?.get; + if ( + typeof querySelectorAll !== 'function' || + typeof contains !== 'function' || + typeof elementId !== 'function' + ) { + return undefined; + } + const isConnected = (element: object): boolean => { + try { + return Reflect.apply(contains, root, [element]) === true; + } catch { + return false; + } + }; + return Object.freeze({ + isConnected, + observe: (callback: () => void): (() => void) => { + if (typeof callback !== 'function') throw new TypeError('reconciliation callback required'); + const observer = new Observer(() => callback()); + observer.observe(root, { childList: true, subtree: true }); + let active = true; + return (): void => { + if (!active) return; + active = false; + observer.disconnect(); + }; + }, + resolve: (elementIds: readonly string[]): SlotReconciliationResolution => { + if (!Array.isArray(elementIds) || elementIds.length === 0) { + return Object.freeze({ status: 'unresolved' }); + } + const elements = Reflect.apply(querySelectorAll, documentTarget, [ + '[id]', + ]) as NodeListOf; + let match: object | undefined; + let matchId: string | undefined; + for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) { + const element = elements.item(elementIndex); + if (!element || !isConnected(element)) continue; + const id = Reflect.apply(elementId, element, []) as string; + let accepted = false; + for (let idIndex = 0; idIndex < elementIds.length; idIndex += 1) { + if (elementIds[idIndex] === id) { + accepted = true; + break; + } + } + if (!accepted) continue; + if (match && match !== element) return Object.freeze({ status: 'ambiguous' }); + match = element; + matchId = id; + } + return match && matchId !== undefined + ? Object.freeze({ status: 'unique', element: match, elementId: matchId }) + : Object.freeze({ status: 'unresolved' }); + }, + }); + } catch { + return undefined; + } +} + /** Construct the document-lifetime slot registry and physical GPT cycle service. */ export function createSlotService(options: SlotServiceOptions): SlotService { const navigationStates = new Map(); @@ -512,15 +616,98 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const placementQuarantine = new Map(); const quarantinedKeysByPhysical = new WeakMap(); const now = options.now ?? (() => performance.now()); + let reconciliationBoundary: SlotReconciliationBoundary | undefined; + let reconciliationObserve: SlotReconciliationBoundary['observe'] | undefined; + let reconciliationIsConnected: SlotReconciliationBoundary['isConnected'] | undefined; + let reconciliationResolve: SlotReconciliationBoundary['resolve'] | undefined; + try { + const candidate = options.reconciliation; + if ( + candidate && + typeof candidate.observe === 'function' && + typeof candidate.isConnected === 'function' && + typeof candidate.resolve === 'function' + ) { + reconciliationBoundary = candidate; + reconciliationObserve = candidate.observe; + reconciliationIsConnected = candidate.isConnected; + reconciliationResolve = candidate.resolve; + } + } catch { + reconciliationBoundary = undefined; + } let placementQuarantineSaturated = false; let placementQuarantinePoisoned = false; let saturationOwnerCount = 0; let disposed = false; let deferInvocations = false; let activation: GoogletagOperation | undefined; + let reconciliationActive = false; const subscriptionsByBinding = new WeakMap(); const bindingSubscriptions = new Set(); + const reconciliationElementIds = ( + record: InternalSlotRecord, + definition: GoogletagReplacementDefinition + ): readonly string[] => { + const values: string[] = [definition.elementId]; + for (let aliasIndex = 0; aliasIndex < record.view.domAliases.length; aliasIndex += 1) { + const alias = record.view.domAliases[aliasIndex]; + if (alias === undefined) continue; + let duplicate = false; + for (let valueIndex = 0; valueIndex < values.length; valueIndex += 1) { + if (values[valueIndex] === alias) { + duplicate = true; + break; + } + } + if (!duplicate) values[values.length] = alias; + } + return Object.freeze(values); + }; + + const resolveReconciliationElement = ( + record: InternalSlotRecord, + definition: GoogletagReplacementDefinition + ): SlotReconciliationResolution | undefined => { + if (!reconciliationBoundary || !reconciliationResolve) return undefined; + try { + const resolution = Reflect.apply(reconciliationResolve, reconciliationBoundary, [ + reconciliationElementIds(record, definition), + ]) as SlotReconciliationResolution; + if ( + !resolution || + (resolution.status !== 'unique' && + resolution.status !== 'unresolved' && + resolution.status !== 'ambiguous') + ) { + return undefined; + } + if ( + resolution.status === 'unique' && + (((typeof resolution.element !== 'object' || resolution.element === null) && + typeof resolution.element !== 'function') || + typeof resolution.elementId !== 'string' || + resolution.elementId.length === 0) + ) { + return undefined; + } + return resolution; + } catch { + return undefined; + } + }; + + const reconciliationElementConnected = (element: object | undefined): boolean => { + if (!reconciliationBoundary || !reconciliationIsConnected) return true; + if (!element) return false; + try { + return Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [element]) === true; + } catch { + return true; + } + }; + const hasPlacementQuarantine = (keys: readonly string[]): boolean => { if (placementQuarantineSaturated || placementQuarantinePoisoned) return true; for (let index = 0; index < keys.length; index += 1) { @@ -650,7 +837,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const prepareReplacementCommit = ( record: InternalSlotRecord, oldPhysical: PhysicalSlot, - replacement: object + replacement: object, + definition = oldPhysical.definition, + domElement = oldPhysical.domElement ): GoogletagReplacementCommitAdmission => { if ( replacement === oldPhysical.slot || @@ -664,7 +853,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (existing) throw new GoogletagReplacementCandidateCollisionError(replacement); const physical: PhysicalSlot = { activeCycle: undefined, - definition: oldPhysical.definition, + definition, + domElement, destroyAttempted: false, lastResponseIdentifier: undefined, ownership: 'trusted_server', @@ -787,6 +977,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const orphan: PhysicalSlot = { activeCycle: undefined, definition: physical.definition, + domElement: undefined, destroyAttempted: true, lastResponseIdentifier: undefined, ownership: 'trusted_server', @@ -1176,11 +1367,338 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } }; + const clearReconciliationTimers = (window: ReconciliationWindow): void => { + if (window.debounceTimer !== undefined) clearTimeout(window.debounceTimer); + if (window.deadlineTimer !== undefined) clearTimeout(window.deadlineTimer); + window.debounceTimer = undefined; + window.deadlineTimer = undefined; + }; + + const cancelReconciliation = (record: InternalSlotRecord): void => { + const window = record.reconciliation; + if (!window || window.terminal) return; + window.terminal = true; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + if (record.reconciliation === window) record.reconciliation = undefined; + }; + + const detachDestroyedReconciliationPhysical = (physical: PhysicalSlot): void => { + releasePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, physical.slot) === physical) { + deleteWeakMapValue(physicalByObject, physical.slot); + } + }; + + const settleReconciliationWork = ( + record: InternalSlotRecord, + physical: PhysicalSlot, + reason: SlotRequestFailure + ): void => { + const cycleIntent = physical.activeCycle?.intent; + if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed(reason)); + if (record.activeIntent) settle(record.activeIntent, failed(reason)); + if (record.queuedIntent) settle(record.queuedIntent, failed(reason)); + physical.activeCycle = undefined; + }; + + const retireFailedReconciliation = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + reason: SlotRequestFailure, + transactionStarted: boolean, + oldSlotDestroyed: boolean + ): void => { + if (window.terminal) return; + window.terminal = true; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + if (record.reconciliation === window) record.reconciliation = undefined; + const physical = window.orphan; + if (record.physical !== physical || physical.ownership !== 'trusted_server') return; + + settleReconciliationWork(record, physical, reason); + record.physical = undefined; + physical.record = undefined; + physical.state = 'retired'; + physical.quarantineReason = 'request'; + physical.destroyAttempted = true; + deleteSetValue(physicalSlots, physical); + if (oldSlotDestroyed) { + detachDestroyedReconciliationPhysical(physical); + return; + } + quarantinePhysicalPlacement(physical); + if (transactionStarted) return; + + let destroyOperation: GoogletagOperation | undefined; + try { + destroyOperation = options.googletag.run((gpt) => + gpt.transactionalReplace( + physical.slot, + undefined, + () => false, + () => { + throw new Error('destroy-only reconciliation cannot commit'); + } + ) + ); + void destroyOperation.result.then( + () => detachDestroyedReconciliationPhysical(physical), + () => undefined + ); + } catch { + destroyOperation?.dispose(); + } + }; + + const completeReconciliation = ( + record: InternalSlotRecord, + window: ReconciliationWindow + ): boolean => { + if (window.terminal || record.reconciliation !== window) return false; + const physical = record.physical; + if ( + !physical || + physical === window.orphan || + physical.ownership !== 'trusted_server' || + physical.record !== record || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + return false; + } + window.terminal = true; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + record.reconciliation = undefined; + record.reconciliationSuccesses += 1; + detachDestroyedReconciliationPhysical(window.orphan); + return true; + }; + + const startReconciliationReplacement = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + resolution: Extract, + finalPass: boolean + ): void => { + const orphan = window.orphan; + const existingDefinition = orphan.definition; + if (!existingDefinition) { + retireFailedReconciliation(record, window, 'slot_unresolved', false, false); + return; + } + const definition = Object.freeze({ + adUnitPath: existingDefinition.adUnitPath, + elementId: resolution.elementId, + sizes: existingDefinition.sizes, + }); + let transactionStarted = false; + let operation: GoogletagOperation | undefined; + try { + operation = options.googletag.run((gpt) => { + transactionStarted = true; + orphan.state = 'retired'; + orphan.quarantineReason = 'request'; + orphan.destroyAttempted = true; + quarantinePhysicalPlacement(orphan); + return gpt.transactionalReplace( + orphan.slot, + definition, + () => + !window.terminal && + record.reconciliation === window && + !record.state.disposed && + record.state.owner.isCurrent() && + ((record.physical === orphan && orphan.ownership === 'trusted_server') || + (record.physical !== undefined && + record.physical !== orphan && + record.physical.record === record && + record.physical.ownership === 'trusted_server')), + (replacement) => + prepareReplacementCommit(record, orphan, replacement, definition, resolution.element) + ); + }); + window.operation = operation; + } catch { + retireFailedReconciliation(record, window, 'gpt_request_failed', transactionStarted, false); + return; + } + + if (record.physical !== orphan) { + completeReconciliation(record, window); + return; + } + if (finalPass && !transactionStarted) { + retireFailedReconciliation(record, window, 'slot_unresolved', transactionStarted, false); + return; + } + void operation.result.then( + (result) => { + if (window.terminal) return; + if (result.status === 'replaced' && completeReconciliation(record, window)) return; + retireFailedReconciliation(record, window, 'gpt_request_failed', true, true); + }, + (error: unknown) => { + const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + retireFailedReconciliation( + record, + window, + 'gpt_request_failed', + transactionStarted, + replacementError?.oldSlotDestroyed === true + ); + } + ); + }; + + const runReconciliationPass = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + finalPass: boolean + ): void => { + if ( + window.terminal || + record.reconciliation !== window || + record.physical !== window.orphan || + window.orphan.ownership !== 'trusted_server' || + record.state.disposed || + !record.state.owner.isCurrent() + ) { + cancelReconciliation(record); + return; + } + if (reconciliationElementConnected(window.orphan.domElement)) { + cancelReconciliation(record); + return; + } + if (window.operation) { + if (record.physical !== window.orphan) completeReconciliation(record, window); + else if (finalPass) { + retireFailedReconciliation(record, window, 'slot_unresolved', false, false); + } + return; + } + const definition = window.orphan.definition; + const resolution = definition && resolveReconciliationElement(record, definition); + if (resolution?.status === 'unique') { + startReconciliationReplacement(record, window, resolution, finalPass); + return; + } + if (finalPass) { + retireFailedReconciliation(record, window, 'slot_unresolved', false, false); + } else { + window.firstPassFinished = true; + } + }; + + const scheduleReconciliationDebounce = ( + record: InternalSlotRecord, + window: ReconciliationWindow + ): void => { + if (window.firstPassFinished || window.operation || window.terminal) return; + if (window.debounceTimer !== undefined) clearTimeout(window.debounceTimer); + window.debounceTimer = setTimeout(() => { + window.debounceTimer = undefined; + runReconciliationPass(record, window, false); + }, GPT_RECONCILIATION_DEBOUNCE_MS); + }; + + const openReconciliation = (record: InternalSlotRecord, physical: PhysicalSlot): void => { + if (record.reconciliationSuccesses >= MAX_SUCCESSFUL_RECONCILIATIONS) { + const instant: ReconciliationWindow = { + debounceTimer: undefined, + deadlineTimer: undefined, + deadlineAt: Number.NEGATIVE_INFINITY, + firstPassFinished: true, + operation: undefined, + orphan: physical, + terminal: false, + }; + record.reconciliation = instant; + retireFailedReconciliation(record, instant, 'reconciliation_capacity', false, false); + return; + } + const openedAt = now(); + if (!Number.isFinite(openedAt) || openedAt < 0) return; + const window: ReconciliationWindow = { + debounceTimer: undefined, + deadlineTimer: undefined, + deadlineAt: openedAt + GPT_RECONCILIATION_WINDOW_MS, + firstPassFinished: false, + operation: undefined, + orphan: physical, + terminal: false, + }; + record.reconciliation = window; + scheduleReconciliationDebounce(record, window); + window.deadlineTimer = setTimeout(() => { + window.deadlineTimer = undefined; + if (window.terminal) return; + const current = now(); + if (Number.isFinite(current) && current < window.deadlineAt) { + window.deadlineTimer = setTimeout( + () => runReconciliationPass(record, window, true), + Math.max(1, window.deadlineAt - current) + ); + return; + } + runReconciliationPass(record, window, true); + }, GPT_RECONCILIATION_WINDOW_MS); + }; + + const inspectNavigationDom = (state: NavigationState): void => { + if (state.disposed || !state.owner.isCurrent()) return; + const records = mapValueSnapshot(state.records); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + const physical = record?.physical; + if (!record || !physical) continue; + if (physical.ownership !== 'trusted_server' || !physical.definition) { + cancelReconciliation(record); + continue; + } + if (reconciliationElementConnected(physical.domElement)) continue; + const existing = record.reconciliation; + if (!existing) openReconciliation(record, physical); + else if (existing.orphan === physical) scheduleReconciliationDebounce(record, existing); + else cancelReconciliation(record); + } + }; + + const installNavigationObserver = (state: NavigationState): boolean => { + if (!reconciliationBoundary || !reconciliationObserve) return true; + if (state.observerRelease) return true; + try { + const release = Reflect.apply(reconciliationObserve, reconciliationBoundary, [ + () => inspectNavigationDom(state), + ]) as () => void; + if (typeof release !== 'function') return false; + state.observerRelease = release; + return true; + } catch { + return false; + } + }; + const disposeNavigationState = (state: NavigationState): void => { if (state.disposed) return; state.disposed = true; + const observerRelease = state.observerRelease; + state.observerRelease = undefined; + try { + observerRelease?.(); + } catch { + // Logical observer ownership is already released. + } const records = mapValueSnapshot(state.records); for (const record of records) { + cancelReconciliation(record); const active = record.activeIntent; const queued = record.queuedIntent; if (active) settle(active, cancelled('navigation_disposed')); @@ -1205,6 +1723,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const state: NavigationState = { disposed: false, nextOrdinal: 0, + observerRelease: undefined, owner, records: new Map(), }; @@ -1212,6 +1731,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { try { owner.onDispose('slot-records', () => disposeNavigationState(state)); disposerInstalled = true; + if (reconciliationActive && !installNavigationObserver(state)) { + throw new Error('reconciliation observer failed'); + } if (!owner.isCurrent() || state.disposed) return undefined; setMapValue(navigationStates, owner.generation, state); if (!owner.isCurrent() || state.disposed) { @@ -1316,6 +1838,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { activeIntent: undefined, physical: undefined, queuedIntent: undefined, + reconciliation: undefined, + reconciliationSuccesses: 0, state, view, }; @@ -1401,6 +1925,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } + const initialResolution = + ownership === 'trusted_server' && definition + ? resolveReconciliationElement(record, definition) + : undefined; + const domElement = + initialResolution?.status === 'unique' ? initialResolution.element : undefined; const slotObject = slot as object; let bindingPlacementKeys: readonly string[]; try { @@ -1434,6 +1964,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const previousRecord = existing.record; const previousOwnership = existing.ownership; const previousDefinition = existing.definition; + const previousDomElement = existing.domElement; const previousPlacementKeys = existing.placementKeys; try { if (!wasStrong) addSetValue(physicalSlots, existing); @@ -1442,14 +1973,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.record = record; existing.ownership = ownership; existing.definition = definition; + existing.domElement = domElement; existing.placementKeys = bindingPlacementKeys; record.physical = existing; + if (ownership === 'publisher') cancelReconciliation(record); return Object.freeze({ ok: true }); } catch { if (record.physical === existing) record.physical = undefined; existing.record = previousRecord; existing.ownership = previousOwnership; existing.definition = previousDefinition; + existing.domElement = previousDomElement; existing.placementKeys = previousPlacementKeys; if (!wasStrong) deleteSetValue(physicalSlots, existing); return Object.freeze({ ok: false, reason: 'stale_owner' }); @@ -1461,6 +1995,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, definition, + domElement, destroyAttempted: false, lastResponseIdentifier: undefined, ownership, @@ -1495,7 +2030,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { resolve = resolveResult; }); const placeholderRecord = - record ?? ({ activeIntent: undefined, queuedIntent: undefined } as InternalSlotRecord); + record ?? + ({ + activeIntent: undefined, + queuedIntent: undefined, + reconciliation: undefined, + reconciliationSuccesses: 0, + } as InternalSlotRecord); const intent: RequestIntent = { completionTimer: undefined, completionDeadlineAt: undefined, @@ -1849,6 +2390,28 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const service: SlotService = Object.freeze({ activate: (): GoogletagOperation => { if (activation) return activation; + if (!reconciliationActive) { + const states = mapValueSnapshot(navigationStates); + const installed: NavigationState[] = []; + for (let index = 0; index < states.length; index += 1) { + const state = states[index]; + if (!state || !installNavigationObserver(state)) { + for (let releaseIndex = installed.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + const installedState = installed[releaseIndex]; + const release = installedState?.observerRelease; + if (installedState) installedState.observerRelease = undefined; + try { + release?.(); + } catch { + // Failed activation retains no observer ownership. + } + } + throw new Error('reconciliation observer failed'); + } + if (state.observerRelease) installed[installed.length] = state; + } + reconciliationActive = true; + } let subscriptions: BindingSubscriptionAdmission | undefined; const operation = options.googletag.run((gpt) => { if (disposed) return; diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 4ca50c798..d129af03a 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -65,7 +65,7 @@ function browserProjection() { }; } -function largeAdmProjection(admLengths: number[]) { +function largeAdmProjection(admLengths: number[]): BrowserAuctionProjectionV1 { return { version: 1, auction: { @@ -752,6 +752,49 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { }; } + function admResponse(admLengths: number[]) { + const projected = largeAdmProjection(admLengths); + const canonical: BrowserAuctionProjectionV1 = { + version: 1, + auction: projected.auction, + bids: projected.bids.map((bid) => ({ + ...bid, + upstreamBidId: bid.rendererReservationId, + })), + }; + return { + canonical, + wire: { + id: canonical.auction.auctionId, + cur: 'USD', + seatbid: [ + { + seat: 'prebid', + bid: canonical.bids.map((bid) => { + if (bid.renderSource.type !== 'adm') throw new Error('expected ADM source'); + return { + id: bid.rendererReservationId, + impid: bid.slot, + price: bid.cpm, + adm: bid.renderSource.adm, + w: bid.renderSource.width, + h: bid.renderSource.height, + ext: { + trusted_server: { + candidate_id: bid.candidateId, + slot_id: bid.slot, + render_source: bid.renderSource, + }, + }, + }; + }), + }, + ], + ext: { trusted_server: { slot_results: canonical.auction } }, + }, + }; + } + it('accepts the exact four-way decision/candidate/impid/slot join', () => { const parsed = parseTrustedServerAuctionResponseV1(response()); @@ -766,6 +809,31 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { ); }); + it('caps the deduplicated canonical projection instead of duplicated ADM wire bytes', () => { + const lengths = Array.from({ length: 16 }, () => 512 * 1024); + lengths[15] = 1; + const baseline = admResponse(lengths).canonical; + const baselineBytes = new TextEncoder().encode(JSON.stringify(baseline)).byteLength; + const exactTail = 1 + MAX_BROWSER_AUCTION_PROJECTION_BYTES - baselineBytes; + expect(exactTail).toBeLessThanOrEqual(512 * 1024); + + for (const [delta, accepted] of [ + [0, true], + [1, false], + ] as const) { + lengths[15] = exactTail + delta; + const { canonical, wire } = admResponse(lengths); + expect(new TextEncoder().encode(JSON.stringify(canonical)).byteLength).toBe( + MAX_BROWSER_AUCTION_PROJECTION_BYTES + delta + ); + expect(new TextEncoder().encode(JSON.stringify(wire)).byteLength).toBeGreaterThan( + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ); + expect(parseBrowserAuctionProjectionV1(canonical) !== undefined).toBe(accepted); + expect(parseTrustedServerAuctionResponseV1(wire) !== undefined).toBe(accepted); + } + }); + it.each([ ['Object', Object.prototype], ['Array', Array.prototype], diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 5c859c2f6..13948ee30 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -12,8 +12,10 @@ import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; import { createRuntimeSession, type NavigationSession } from '../../src/kernel/sessions'; import { MAX_ACTIVE_SLOT_RECORDS, + createBrowserSlotReconciliationBoundary, createSlotService, type GptSlotBinding, + type SlotReconciliationBoundary, type SlotRegistration, type SlotService, } from '../../src/services/slots'; @@ -208,6 +210,73 @@ function bindTrustedSlot(service: SlotService, navigation: NavigationSession, id return slot; } +function createReconciliationBoundary() { + let listener: (() => void) | undefined; + const connected = new WeakSet(); + const elements = new Map(); + const observe = vi.fn((callback: () => void) => { + listener = callback; + return vi.fn(() => { + if (listener === callback) listener = undefined; + }); + }); + const boundary: SlotReconciliationBoundary = Object.freeze({ + observe, + isConnected: (element: object) => connected.has(element), + resolve: (elementIds: readonly string[]) => { + const matches = new Set(); + let matchedId: string | undefined; + for (const elementId of elementIds) { + for (const element of elements.get(elementId) ?? []) { + if (!connected.has(element)) continue; + matches.add(element); + matchedId = elementId; + } + } + if (matches.size === 0) return Object.freeze({ status: 'unresolved' as const }); + if (matches.size !== 1 || matchedId === undefined) { + return Object.freeze({ status: 'ambiguous' as const }); + } + return Object.freeze({ + status: 'unique' as const, + element: [...matches][0]!, + elementId: matchedId, + }); + }, + }); + const put = (elementId: string, element: object): void => { + connected.add(element); + elements.set(elementId, [element]); + }; + const replace = (elementId: string, element: object): void => { + const previous = elements.get(elementId) ?? []; + for (const candidate of previous) connected.delete(candidate); + put(elementId, element); + listener?.(); + }; + const replaceAmbiguously = (elementId: string, replacements: readonly object[]): void => { + const previous = elements.get(elementId) ?? []; + for (const candidate of previous) connected.delete(candidate); + for (const replacement of replacements) connected.add(replacement); + elements.set(elementId, [...replacements]); + listener?.(); + }; + const disconnect = (elementId: string): void => { + for (const candidate of elements.get(elementId) ?? []) connected.delete(candidate); + elements.delete(elementId); + listener?.(); + }; + return { + boundary, + disconnect, + observe, + put, + replace, + replaceAmbiguously, + trigger: () => listener?.(), + }; +} + describe('slot registry', () => { afterEach(() => vi.useRealTimers()); @@ -440,6 +509,317 @@ describe('slot registry', () => { }); }); +describe('navigation-owned DOM reconciliation', () => { + afterEach(() => vi.useRealTimers()); + + it('reconciles a TS slot whose original DOM element was already absent at adoption', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.put('slot-div', {}); + dom.trigger(); + await vi.advanceTimersByTimeAsync(250); + + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + }); + + it('debounces an exact disconnected TS slot through the 249/250 ms boundary', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(249); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + + const request = service.request({ + intentId: 'after-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(request.status).toBe('active'); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(gpt.defineSlot.mock.results[0]?.value); + }); + + it('runs one final unresolved pass at 5,000 ms and settles exact work', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(2_999); + const request = service.request({ + intentId: 'orphaned', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(2_000); + expect(request.status).toBe('active'); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + }); + + it('commits a unique replacement found only by the final 5,000 ms pass', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(250); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(4_749); + dom.put('slot-div', {}); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(gpt.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/network/slot', + [[300, 250]], + 'slot-div' + ); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it('keeps an ambiguous replacement unresolved through the final pass', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.replaceAmbiguously('slot-div', [{}, {}]); + await vi.advanceTimersByTimeAsync(2_999); + const request = service.request({ + intentId: 'ambiguous', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(2_001); + await expect(request.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it.each(['destroy', 'define'] as const)( + 'settles %s transaction failure as gpt_request_failed without a second physical slot', + async (failure) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + if (failure === 'destroy') gpt.destroySlots.mockReturnValue(false); + else gpt.defineSlot.mockReturnValueOnce(undefined); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: `failed-${failure}`, + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(gpt.defineSlot).toHaveBeenCalledTimes(failure === 'define' ? 1 : 0); + expect(service.snapshotForTest().physicalSlots).toBe(0); + } + ); + + it('allows two successful rebinds and fails a third disconnect immediately', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + expect(gpt.defineSlot).toHaveBeenCalledTimes(2); + + const request = service.request({ + intentId: 'capacity', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + dom.disconnect('slot-div'); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'reconciliation_capacity', + }); + expect(gpt.defineSlot).toHaveBeenCalledTimes(2); + expect(gpt.destroySlots).toHaveBeenCalledTimes(3); + }); + + it('cancels reconciliation on publisher transfer and disconnects with navigation', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + await vi.advanceTimersByTimeAsync(5_000); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + + navigation.dispose(); + expect(dom.observe).toHaveBeenCalledTimes(1); + dom.trigger(); + await vi.runAllTimersAsync(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + }); +}); + +describe('browser reconciliation boundary', () => { + it('resolves only one exact connected element and releases its observer', async () => { + const boundary = createBrowserSlotReconciliationBoundary(document, MutationObserver); + expect(boundary).toBeDefined(); + if (!boundary) throw new Error('Expected the browser reconciliation boundary'); + const host = document.createElement('section'); + const first = document.createElement('div'); + first.id = 'tsjs-reconciliation-exact'; + host.append(first); + document.body.append(host); + const callback = vi.fn(); + const release = boundary.observe(callback); + + expect(boundary.resolve(['tsjs-reconciliation-exact'])).toEqual({ + status: 'unique', + element: first, + elementId: 'tsjs-reconciliation-exact', + }); + expect(boundary.isConnected(first)).toBe(true); + + const duplicate = document.createElement('div'); + duplicate.id = first.id; + host.append(duplicate); + await vi.waitFor(() => expect(callback).toHaveBeenCalled()); + expect(boundary.resolve([first.id])).toEqual({ status: 'ambiguous' }); + + const callsBeforeRelease = callback.mock.calls.length; + release(); + host.remove(); + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(callsBeforeRelease); + expect(boundary.isConnected(first)).toBe(false); + expect(boundary.resolve([first.id])).toEqual({ status: 'unresolved' }); + }); +}); + function createReplacementHarness() { const replacement = { addService: vi.fn() }; const destroySlots = vi.fn((_slots: readonly object[]) => true); From 3ba8b30abb3df4255079cb3a513af458643c9779 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:53:00 -0700 Subject: [PATCH 322/494] Harden GPT slot reconciliation races --- .../lib/src/services/slots.ts | 26 ++++++++++- .../lib/test/services/slots.test.ts | 44 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 625c5ec54..49477db0f 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -561,7 +561,13 @@ export function createBrowserSlotReconciliationBoundary( isConnected, observe: (callback: () => void): (() => void) => { if (typeof callback !== 'function') throw new TypeError('reconciliation callback required'); - const observer = new Observer(() => callback()); + const observer = new Observer(() => { + try { + callback(); + } catch { + // DOM observation cannot escape the service boundary. + } + }); observer.observe(root, { childList: true, subtree: true }); let active = true; return (): void => { @@ -1477,7 +1483,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { window.operation = undefined; record.reconciliation = undefined; record.reconciliationSuccesses += 1; + const active = record.activeIntent; + if ( + active && + !active.terminal && + (active.requestStartedAt !== undefined || window.orphan.activeCycle?.intent === active) + ) { + settle(active, failed('gpt_request_failed')); + } + window.orphan.activeCycle = undefined; detachDestroyedReconciliationPhysical(window.orphan); + advanceQueued(record); return true; }; @@ -1659,7 +1675,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const record = records[index]; const physical = record?.physical; if (!record || !physical) continue; - if (physical.ownership !== 'trusted_server' || !physical.definition) { + if ( + physical.ownership !== 'trusted_server' || + physical.state !== 'live' || + physical.publisherIntentCount > 0 || + !physical.definition + ) { cancelReconciliation(record); continue; } @@ -2503,6 +2524,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical = weakMapValue(physicalByObject, slot); if (!physical) return false; const record = physical.record; + if (record) cancelReconciliation(record); const cycleIntent = physical.activeCycle?.intent; if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 13948ee30..2d7b2d124 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -578,6 +578,50 @@ describe('navigation-owned DOM reconciliation', () => { expect(gpt.display).toHaveBeenCalledExactlyOnceWith(gpt.defineSlot.mock.results[0]?.value); }); + it('settles an invocation tied to the orphan before publishing the replacement', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const orphan = bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: 'before-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(orphan); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + await vi.advanceTimersByTimeAsync(3_000); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([orphan]); + + service.request({ + intentId: 'after-rebind', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + expect(gpt.display).toHaveBeenLastCalledWith(gpt.defineSlot.mock.results[0]?.value); + }); + it('runs one final unresolved pass at 5,000 ms and settles exact work', async () => { vi.useFakeTimers(); vi.setSystemTime(0); From ed44185ed8dd6e3b3598d6f21f634ac25fb42543 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:55:13 -0700 Subject: [PATCH 323/494] Bound shared auction registration graphs --- .../lib/src/core/registry.ts | 163 ++++++++++++++---- .../lib/test/core/registry.test.ts | 25 +++ 2 files changed, 157 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 0e701b973..b94789eb5 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -59,9 +59,27 @@ interface JsonMeasureFrame { readonly entries: readonly Readonly<{ key: string; value: unknown }>[]; readonly source: object; bytes: number; + structureEntries: number; index: number; } +interface JsonMeasurement { + readonly bytes: number; + readonly snapshot?: JsonContainerSnapshot; + readonly structureEntries: number; +} + +interface PendingProgrammaticBid { + readonly bidder: string; + readonly params?: object; +} + +interface PendingProgrammaticAdUnit { + readonly code: string; + readonly mediaTypes: ProgrammaticAdUnit['mediaTypes']; + readonly bids?: readonly PendingProgrammaticBid[]; +} + function ownDataRecord(value: unknown): Record | undefined { try { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; @@ -140,13 +158,20 @@ function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined } /** Copy JSON data without invoking accessors or retaining publisher-owned objects. */ -function copyJsonRecord(value: unknown): Readonly> | undefined { +function copyJsonRecord( + value: unknown, + completed = new WeakMap | unknown[]>(), + measurements?: WeakMap +): Readonly> | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; - const rootSnapshot = snapshotJsonContainer(value); + const completedRoot = completed.get(value); + if (completedRoot) { + return Array.isArray(completedRoot) ? undefined : completedRoot; + } + const rootSnapshot = measurements?.get(value)?.snapshot ?? snapshotJsonContainer(value); if (!rootSnapshot || rootSnapshot.array) return undefined; const root: Record = {}; const active = new Set([value]); - const completed = new WeakMap | unknown[]>(); const stack: JsonCloneFrame[] = [ { index: 0, output: root, snapshot: rootSnapshot, source: value }, ]; @@ -188,7 +213,8 @@ function copyJsonRecord(value: unknown): Readonly> | und }); continue; } - const childSnapshot = snapshotJsonContainer(entry.value); + const childSnapshot = + measurements?.get(entry.value)?.snapshot ?? snapshotJsonContainer(entry.value); if (!childSnapshot) return undefined; const child: Record | unknown[] = childSnapshot.array ? [] : {}; Object.defineProperty(frame.output, entry.key, { @@ -315,29 +341,52 @@ function boundedBytes(left: number, right: number): number { return left > MAX_AUCTION_BODY_BYTES - right ? MAX_AUCTION_BODY_BYTES + 1 : left + right; } +function boundedStructureEntries(left: number, right: number): number { + return left > MAX_JSON_STRUCTURE_ENTRIES - right ? MAX_JSON_STRUCTURE_ENTRIES + 1 : left + right; +} + /** Exact JSON byte measurement that never consults `toJSON` or publisher prototypes. */ -function measureJsonBytes(value: unknown): number | undefined { +function measureJson( + value: unknown, + memo = new WeakMap() +): JsonMeasurement | undefined { const primitive = primitiveJsonBytes(value); - if (primitive !== undefined) return primitive; + if (primitive !== undefined) return Object.freeze({ bytes: primitive, structureEntries: 0 }); if (typeof value !== 'object' || value === null) return undefined; + const completedRoot = memo.get(value); + if (completedRoot) return completedRoot; const root = snapshotJsonContainer(value); if (!root) return undefined; - const memo = new WeakMap(); const active = new Set([value]); const stack: JsonMeasureFrame[] = [ - { array: root.array, bytes: 2, entries: root.entries, index: 0, source: value }, + { + array: root.array, + bytes: 2, + entries: root.entries, + index: 0, + source: value, + structureEntries: 1, + }, ]; while (stack.length > 0) { const frame = stack[stack.length - 1]; if (!frame) return undefined; if (frame.index >= frame.entries.length) { - memo.set(frame.source, frame.bytes); + const measurement = Object.freeze({ + bytes: frame.bytes, + snapshot: Object.freeze({ array: frame.array, entries: frame.entries }), + structureEntries: frame.structureEntries, + }); + memo.set(frame.source, measurement); active.delete(frame.source); stack.pop(); const parent = stack[stack.length - 1]; - if (!parent) return frame.bytes; - parent.bytes = boundedBytes(parent.bytes, frame.bytes); - if (parent.bytes > MAX_AUCTION_BODY_BYTES) return parent.bytes; + if (!parent) return measurement; + parent.bytes = boundedBytes(parent.bytes, measurement.bytes); + parent.structureEntries = boundedStructureEntries( + parent.structureEntries, + measurement.structureEntries - 1 + ); continue; } const entry = frame.entries[frame.index]; @@ -347,11 +396,10 @@ function measureJsonBytes(value: unknown): number | undefined { const prefix = (entryIndex === 0 ? 0 : 1) + (frame.array ? 0 : encodedJsonStringBytes(entry.key) + 1); frame.bytes = boundedBytes(frame.bytes, prefix); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + frame.structureEntries = boundedStructureEntries(frame.structureEntries, 1); const childPrimitive = primitiveJsonBytes(entry.value); if (childPrimitive !== undefined) { frame.bytes = boundedBytes(frame.bytes, childPrimitive); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; continue; } if (typeof entry.value !== 'object' || entry.value === null || active.has(entry.value)) { @@ -359,8 +407,11 @@ function measureJsonBytes(value: unknown): number | undefined { } const completed = memo.get(entry.value); if (completed !== undefined) { - frame.bytes = boundedBytes(frame.bytes, completed); - if (frame.bytes > MAX_AUCTION_BODY_BYTES) return frame.bytes; + frame.bytes = boundedBytes(frame.bytes, completed.bytes); + frame.structureEntries = boundedStructureEntries( + frame.structureEntries, + completed.structureEntries - 1 + ); continue; } const child = snapshotJsonContainer(entry.value); @@ -372,6 +423,7 @@ function measureJsonBytes(value: unknown): number | undefined { entries: child.entries, index: 0, source: entry.value, + structureEntries: 1, }); } return undefined; @@ -407,7 +459,8 @@ export function prepareProgrammaticAdUnits( const occupied = snapshotKnownSlots(knownSlots); const seen = new Set(); - const prepared: ProgrammaticAdUnit[] = []; + const pending: PendingProgrammaticAdUnit[] = []; + const measurementMemo = new WeakMap(); for (let index = 0; index < units.length; index += 1) { const unit = ownDataRecord(units[index]); if ( @@ -459,11 +512,11 @@ export function prepareProgrammaticAdUnits( sizes.push(Object.freeze([dimensions[0] as number, dimensions[1] as number])); } - let bids: ProgrammaticAdUnit['bids']; + let bids: readonly PendingProgrammaticBid[] | undefined; if (unit.bids !== undefined) { const rawBids = ownDataArray(unit.bids, MAX_JSON_STRUCTURE_ENTRIES); if (!rawBids) throw new AdUnitRegistrationError('invalid_bids', index); - const copiedBids: Array[number]> = []; + const pendingBids: PendingProgrammaticBid[] = []; for (const rawBid of rawBids) { const bid = ownDataRecord(rawBid); if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { @@ -476,19 +529,25 @@ export function prepareProgrammaticAdUnits( ) { throw new AdUnitRegistrationError('invalid_bidder', index); } - let params: Readonly> | undefined; + let params: object | undefined; if (bid.params !== undefined) { - params = copyJsonRecord(bid.params); - if (!params) throw new AdUnitRegistrationError('invalid_params', index); + if (typeof bid.params !== 'object' || bid.params === null || Array.isArray(bid.params)) { + throw new AdUnitRegistrationError('invalid_params', index); + } + const measurement = measureJson(bid.params, measurementMemo); + if (!measurement || measurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES) { + throw new AdUnitRegistrationError('invalid_params', index); + } + params = bid.params; } - copiedBids.push( + pendingBids.push( Object.freeze({ bidder: bid.bidder, ...(params === undefined ? {} : { params }) }) ); } - bids = Object.freeze(copiedBids); + bids = Object.freeze(pendingBids); } - prepared.push( + pending.push( Object.freeze({ code: unit.code, mediaTypes: Object.freeze({ @@ -499,16 +558,58 @@ export function prepareProgrammaticAdUnits( ); } - const unitsBytes = measureJsonBytes(prepared); - if (unitsBytes === undefined) throw new AdUnitRegistrationError('invalid_params'); - // `{"adUnits":` + encoded array + `,"config":{}}`. - if (boundedBytes(24, unitsBytes) > MAX_AUCTION_BODY_BYTES) { + const bodyMeasurement = measureJson({ adUnits: pending, config: {} }, measurementMemo); + if (!bodyMeasurement) throw new AdUnitRegistrationError('invalid_params'); + if ( + bodyMeasurement.bytes > MAX_AUCTION_BODY_BYTES || + bodyMeasurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES + ) { throw new AdUnitRegistrationError('request_body_too_large'); } - if (occupied.size + prepared.length > MAX_ACTIVE_SLOT_RECORDS) { + if (occupied.size + pending.length > MAX_ACTIVE_SLOT_RECORDS) { throw new AdUnitRegistrationError('registry_capacity'); } - return Object.freeze(prepared); + + const completedCopies = new WeakMap | unknown[]>(); + const prepared: ProgrammaticAdUnit[] = []; + for (let index = 0; index < pending.length; index += 1) { + const unit = pending[index]; + if (!unit) throw new AdUnitRegistrationError('invalid_unit', index); + let bids: ProgrammaticAdUnit['bids']; + if (unit.bids !== undefined) { + const copiedBids: Array[number]> = []; + for (let bidIndex = 0; bidIndex < unit.bids.length; bidIndex += 1) { + const bid = unit.bids[bidIndex]; + if (!bid) throw new AdUnitRegistrationError('invalid_bids', index); + let params: Readonly> | undefined; + if (bid.params !== undefined) { + params = copyJsonRecord(bid.params, completedCopies, measurementMemo); + if (!params) throw new AdUnitRegistrationError('invalid_params', index); + } + copiedBids.push( + Object.freeze({ bidder: bid.bidder, ...(params === undefined ? {} : { params }) }) + ); + } + bids = Object.freeze(copiedBids); + } + prepared.push( + Object.freeze({ + code: unit.code, + mediaTypes: unit.mediaTypes, + ...(bids === undefined ? {} : { bids }), + }) + ); + } + const frozenPrepared = Object.freeze(prepared); + const finalMeasurement = measureJson({ adUnits: frozenPrepared, config: {} }); + if (!finalMeasurement) throw new AdUnitRegistrationError('invalid_params'); + if ( + finalMeasurement.bytes > MAX_AUCTION_BODY_BYTES || + finalMeasurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES + ) { + throw new AdUnitRegistrationError('request_body_too_large'); + } + return frozenPrepared; } export function addAdUnitsResult(units: readonly ProgrammaticAdUnit[]): AddAdUnitsResult { diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index ff44b062e..b6ab072bc 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -197,6 +197,31 @@ describe('registry', () => { ); }); + it('bounds validation work before rejecting repeated shared params by aggregate body size', () => { + let ownKeysCalls = 0; + const sharedParams = new Proxy( + Object.fromEntries( + Array.from({ length: 16_384 }, (_, index) => [`p${index.toString(36)}`, 'x']) + ), + { + ownKeys: (target) => { + ownKeysCalls += 1; + return Reflect.ownKeys(target); + }, + } + ); + const candidates = Array.from({ length: 32 }, (_, index) => ({ + ...unit(`shared-${index}`), + bids: [{ bidder: 'fictional', params: sharedParams }], + })); + + expectRegistrationError( + () => prepareProgrammaticAdUnits(candidates, new Set()), + 'request_body_too_large' + ); + expect(ownKeysCalls).toBeLessThanOrEqual(4); + }); + it('serializes detached auction data without invoking inherited toJSON hooks', () => { const prepared = prepareProgrammaticAdUnits(unit(), new Set()); const context = Object.freeze({ segments: Object.freeze(['one']) }); From 445901753c2be55034d8b3646b865766360113cf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:56:32 -0700 Subject: [PATCH 324/494] Retain failed GPT reconciliation identities --- .../lib/src/services/slots.ts | 70 +++++--- .../lib/test/services/slots.test.ts | 157 +++++++++++++++++- 2 files changed, 197 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 49477db0f..05abfd982 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -910,6 +910,37 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }); }; + const quarantineReplacementOrphan = ( + source: PhysicalSlot, + orphanedSlot: object | undefined + ): void => { + if (!orphanedSlot || orphanedSlot === source.slot) return; + const orphan: PhysicalSlot = { + activeCycle: undefined, + definition: source.definition, + domElement: undefined, + destroyAttempted: true, + lastResponseIdentifier: undefined, + ownership: 'trusted_server', + placementKeys: source.placementKeys, + publisherIntentCount: 0, + quarantineReason: 'request', + record: undefined, + saturationOwner: false, + slot: orphanedSlot, + state: 'quarantined', + }; + try { + setWeakMapValue(physicalByObject, orphan.slot, orphan); + if (weakMapValue(physicalByObject, orphan.slot) !== orphan) { + throw new Error('orphan publication failed'); + } + quarantinePhysicalPlacement(orphan); + } catch { + placementQuarantinePoisoned = true; + } + }; + const recoverRequestTimeout = (record: InternalSlotRecord, physical: PhysicalSlot): void => { if (physical.destroyAttempted) { physical.state = 'quarantined'; @@ -979,32 +1010,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) { detachDestroyedOld(); } - if (replacementError?.orphanedSlot && replacementError.orphanedSlot !== physical.slot) { - const orphan: PhysicalSlot = { - activeCycle: undefined, - definition: physical.definition, - domElement: undefined, - destroyAttempted: true, - lastResponseIdentifier: undefined, - ownership: 'trusted_server', - placementKeys: physical.placementKeys, - publisherIntentCount: 0, - quarantineReason: 'request', - record: undefined, - saturationOwner: false, - slot: replacementError.orphanedSlot, - state: 'quarantined', - }; - try { - setWeakMapValue(physicalByObject, orphan.slot, orphan); - if (weakMapValue(physicalByObject, orphan.slot) !== orphan) { - throw new Error('orphan publication failed'); - } - quarantinePhysicalPlacement(orphan); - } catch { - placementQuarantinePoisoned = true; - } - } + quarantineReplacementOrphan(physical, replacementError?.orphanedSlot); failQueued(record, 'gpt_request_failed'); } ); @@ -1551,6 +1557,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } if (finalPass && !transactionStarted) { + void operation.result.then( + () => undefined, + () => undefined + ); retireFailedReconciliation(record, window, 'slot_unresolved', transactionStarted, false); return; } @@ -1562,12 +1572,18 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }, (error: unknown) => { const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + const reusedOldIdentity = replacementError?.orphanedSlot === orphan.slot; + const oldSlotDestroyed = + replacementError?.oldSlotDestroyed === true && + replacementError.preserveOldQuarantine !== true && + !reusedOldIdentity; + quarantineReplacementOrphan(orphan, replacementError?.orphanedSlot); retireFailedReconciliation( record, window, 'gpt_request_failed', transactionStarted, - replacementError?.oldSlotDestroyed === true + oldSlotDestroyed ); } ); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 2d7b2d124..4d45887f8 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -512,6 +512,33 @@ describe('slot registry', () => { describe('navigation-owned DOM reconciliation', () => { afterEach(() => vi.useRealTimers()); + it('preserves the physical slot when DOM connectivity cannot be established', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: Object.freeze({ + ...dom.boundary, + isConnected: () => { + throw new Error('fictional DOM connectivity failure'); + }, + }), + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.trigger(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(gpt.destroySlots).not.toHaveBeenCalled(); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + }); + it('reconciles a TS slot whose original DOM element was already absent at adoption', async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -719,14 +746,18 @@ describe('navigation-owned DOM reconciliation', () => { expect(gpt.destroySlots).toHaveBeenCalledTimes(1); }); - it.each(['destroy', 'define'] as const)( + it.each(['destroy_false', 'destroy_throw', 'define'] as const)( 'settles %s transaction failure as gpt_request_failed without a second physical slot', async (failure) => { vi.useFakeTimers(); vi.setSystemTime(0); const gpt = createGptHarness(); - if (failure === 'destroy') gpt.destroySlots.mockReturnValue(false); - else gpt.defineSlot.mockReturnValueOnce(undefined); + if (failure === 'destroy_false') gpt.destroySlots.mockReturnValue(false); + else if (failure === 'destroy_throw') { + gpt.destroySlots.mockImplementation(() => { + throw new Error('fictional destroy failure'); + }); + } else gpt.defineSlot.mockReturnValueOnce(undefined); const dom = createReconciliationBoundary(); dom.put('slot-div', {}); const service = createSlotService({ @@ -758,6 +789,126 @@ describe('navigation-owned DOM reconciliation', () => { } ); + it('quarantines an exact replacement candidate the adapter could not destroy', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const orphan = Object.freeze({ orphan: true }); + const gpt = createGptHarness({ orphanOnReplace: orphan }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + const request = service.request({ + intentId: 'orphaned-replacement', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await vi.advanceTimersByTimeAsync(0); + + dom.replace('slot-div', {}); + await vi.advanceTimersByTimeAsync(250); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + const replacementBinding = { + definition: { + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'trusted_server' as const, + slot: Object.freeze({ replacementAfterOrphan: true }), + }; + expect(service.adoptGptSlot(navigation.generation, 'slot', replacementBinding)).toEqual({ + ok: false, + reason: 'slot_quarantined', + }); + + expect(service.recordPublisherDestruction(orphan)).toBe(true); + expect(service.adoptGptSlot(navigation.generation, 'slot', replacementBinding)).toEqual({ + ok: true, + }); + }); + + it('lets expiry beat a final-pass replacement that cannot commit synchronously', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + await Promise.resolve(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(4_749); + dom.put('slot-div', {}); + const request = service.request({ + intentId: 'expiry-wins', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(1); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'slot_unresolved', + }); + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('lets publisher ownership transfer cancel a queued reconciliation transaction', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + await Promise.resolve(); + + dom.replace('slot-div', {}); + vi.advanceTimersByTime(250); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + ownership: 'publisher', + slot, + }) + ).toEqual({ ok: true }); + await Promise.resolve(); + await Promise.resolve(); + + expect(gpt.defineSlot).not.toHaveBeenCalled(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + it('allows two successful rebinds and fails a third disconnect immediately', async () => { vi.useFakeTimers(); vi.setSystemTime(0); From c597c37daf9ed30045fafc40ab8a672f0a2212e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:00:11 -0700 Subject: [PATCH 325/494] Harden Task 15 validation intrinsics --- .../src/core/contracts/auction_projection.ts | 102 ++++++++++++------ .../lib/src/core/contracts/request_ads.ts | 20 +++- .../lib/src/core/registry.ts | 30 ++++-- .../lib/test/core/auction.test.ts | 48 +++++++++ .../lib/test/core/registry.test.ts | 52 +++++++++ .../lib/test/core/request.test.ts | 50 +++++++++ 6 files changed, 255 insertions(+), 47 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts index 9a23db9ee..25b7fd702 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -19,7 +19,10 @@ export const MAX_AUCTION_RESULTS = 256; const MAX_TARGETING_ENTRIES = 32; const MAX_ADM_BYTES = 512 * 1024; const MAX_URL_BYTES = 4096; +const reflectApplyIntrinsic = Reflect.apply; const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; +const regExpTestIntrinsic = RegExp.prototype.test; const candidateIdPattern = /^[A-Za-z0-9_-]{12}$/; const reservationIdPattern = /^r1_[A-Za-z0-9_-]{22}$/; const auctionIdPattern = /^[A-Za-z0-9._:-]{1,128}$/; @@ -55,7 +58,9 @@ export function ownDataObject( return undefined; } const snapshot: Record = Object.create(null) as Record; - for (const name of names) { + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (name === undefined) return undefined; const descriptor = Object.getOwnPropertyDescriptor(value, name); if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; snapshot[name] = descriptor.value; @@ -85,18 +90,20 @@ export function ownDataArray(value: unknown, maximum: number): unknown[] | undef } } -function validUnicodeScalars(value: string): boolean { +function unicodeScalarCount(value: string): number | undefined { + let scalars = 0; for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + if (!(next >= 0xdc00 && next <= 0xdfff)) return undefined; index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { - return false; + return undefined; } + scalars += 1; } - return true; + return scalars; } function hasAsciiControl(value: string): boolean { @@ -112,16 +119,21 @@ export function validBoundedString( maximumBytes: number, options: { allowControls?: boolean; maximumScalars?: number } = {} ): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + const scalarCount = unicodeScalarCount(value); return ( - typeof value === 'string' && - value.length > 0 && - validUnicodeScalars(value) && + scalarCount !== undefined && (options.allowControls === true || !hasAsciiControl(value)) && - textEncoder.encode(value).length <= maximumBytes && - (options.maximumScalars === undefined || Array.from(value).length <= options.maximumScalars) + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .length <= maximumBytes && + (options.maximumScalars === undefined || scalarCount <= options.maximumScalars) ); } +function matches(pattern: RegExp, value: string): boolean { + return reflectApplyIntrinsic(regExpTestIntrinsic, pattern, [value]) as boolean; +} + export function validDimension(value: unknown): value is number { return ( typeof value === 'number' && @@ -133,11 +145,11 @@ export function validDimension(value: unknown): value is number { } export function isAuctionCandidateIdV1(value: unknown): value is string { - return typeof value === 'string' && candidateIdPattern.test(value); + return typeof value === 'string' && matches(candidateIdPattern, value); } export function isAuctionProviderIdV1(value: unknown): value is string { - return typeof value === 'string' && providerPattern.test(value); + return typeof value === 'string' && matches(providerPattern, value); } function boundedJsonBytes(left: number, right: number, maximum: number): number { @@ -211,7 +223,8 @@ export function jsonUtf8ByteLength(value: unknown): number { const root = snapshotJsonForMeasurement(value); if (!root) return Number.POSITIVE_INFINITY; const memo = new WeakMap(); - const active = new Set([value]); + const active = new Set(); + active.add(value); const stack: JsonMeasureFrame[] = [{ ...root, bytes: 2, index: 0, source: value }]; while (stack.length > 0) { const frame = stack[stack.length - 1]; @@ -271,7 +284,7 @@ export function jsonUtf8ByteLength(value: unknown): number { /** Whether a value is one exact server-minted renderer reservation identity. */ export function isRendererReservationIdV1(value: unknown): value is string { - return typeof value === 'string' && reservationIdPattern.test(value); + return typeof value === 'string' && matches(reservationIdPattern, value); } /** Validate and copy one exact browser render-source contract. */ @@ -283,18 +296,30 @@ export function parseBidRenderSourceV1( if (!record || typeof record.type !== 'string') return undefined; if (record.type === 'aps') { - const keys = [ - 'type', - 'version', - 'accountId', - 'bidId', - ...(Object.prototype.hasOwnProperty.call(record, 'creativeId') ? ['creativeId'] : []), - 'tagType', - 'creativeUrl', - 'aaxResponse', - 'width', - 'height', - ]; + const keys = Object.prototype.hasOwnProperty.call(record, 'creativeId') + ? [ + 'type', + 'version', + 'accountId', + 'bidId', + 'creativeId', + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ] + : [ + 'type', + 'version', + 'accountId', + 'bidId', + 'tagType', + 'creativeUrl', + 'aaxResponse', + 'width', + 'height', + ]; if (!ownDataObject(value, keys)) return undefined; const renderer = validateApsRenderer(record); if (!renderer) return undefined; @@ -345,7 +370,7 @@ export function parseBidRenderSourceV1( !source || source.version !== 1 || typeof source.cacheId !== 'string' || - !cacheIdPattern.test(source.cacheId) || + !matches(cacheIdPattern, source.cacheId) || !validBoundedString(source.fetchUrl, MAX_URL_BYTES) || !validDimension(source.width) || !validDimension(source.height) || @@ -403,14 +428,15 @@ export function parseBidRenderSourceV1( export function parseAuctionDecisionSetV1(value: unknown): AuctionDecisionSetV1 | undefined { const record = ownDataObject(value, ['version', 'auctionId', 'results']); if (!record || record.version !== 1 || typeof record.auctionId !== 'string') return undefined; - if (!auctionIdPattern.test(record.auctionId)) return undefined; + if (!matches(auctionIdPattern, record.auctionId)) return undefined; const results = ownDataArray(record.results, MAX_AUCTION_RESULTS); if (!results) return undefined; const parsed: SlotAuctionDecisionV1[] = []; const slots = new Set(); const candidates = new Set(); - for (const raw of results) { + for (let index = 0; index < results.length; index += 1) { + const raw = results[index]; const base = ownDataObject(raw); if (!base || !validBoundedString(base.slot, 256) || slots.has(base.slot)) return undefined; slots.add(base.slot); @@ -456,12 +482,19 @@ function parseTargeting(value: unknown): Record | undefined { const entries = Object.entries(record); if (entries.length > MAX_TARGETING_ENTRIES) return undefined; const targeting: Record = {}; - for (const [key, entry] of entries.sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0 - )) { + entries.sort((leftEntry, rightEntry) => { + const left = leftEntry[0]; + const right = rightEntry[0]; + return left < right ? -1 : left > right ? 1 : 0; + }); + for (let index = 0; index < entries.length; index += 1) { + const pair = entries[index]; + if (!pair) return undefined; + const key = pair[0]; + const entry = pair[1]; if ( key === 'hb_adid' || - !targetingKeyPattern.test(key) || + !matches(targetingKeyPattern, key) || !validBoundedString(entry, 160, { maximumScalars: 40 }) ) { return undefined; @@ -538,7 +571,8 @@ export function parseBrowserAuctionProjectionV1( const bids: BrowserAuctionBidV1[] = []; const candidateIds = new Set(); const reservationIds = new Set(); - for (const raw of rawBids) { + for (let index = 0; index < rawBids.length; index += 1) { + const raw = rawBids[index]; const bid = parseBrowserBid(raw, cachePolicy); if ( !bid || diff --git a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts index 59b751b33..470a99bb3 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -1,5 +1,10 @@ const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; const REQUEST_ADS_MAX_SLOTS = 256; +const reflectApplyIntrinsic = Reflect.apply; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; +const regExpTestIntrinsic = RegExp.prototype.test; +const loneSurrogatePattern = /[\uD800-\uDFFF]/u; const abortSignalAbortedGetter = typeof AbortSignal === 'undefined' ? undefined @@ -37,7 +42,10 @@ function ownDataOptions(value: unknown): Record | undefined { if (prototype !== Object.prototype && prototype !== null) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; const output: Record = Object.create(null) as Record; - for (const key of Object.getOwnPropertyNames(value)) { + const names = Object.getOwnPropertyNames(value); + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + if (key === undefined) return undefined; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; output[key] = descriptor.value; @@ -83,7 +91,7 @@ function ownDataSlots(value: unknown): readonly unknown[] | undefined { function readAbortSignal(signal: unknown): boolean | undefined { try { return typeof abortSignalAbortedGetter === 'function' - ? (Reflect.apply(abortSignalAbortedGetter, signal, []) as boolean) + ? (reflectApplyIntrinsic(abortSignalAbortedGetter, signal, []) as boolean) : undefined; } catch { return undefined; @@ -123,13 +131,15 @@ export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOp if (rawSlots.length === 0) throw new RequestAdsInputError('empty_slots'); const seen = new Set(); const copy: string[] = []; - for (const slot of rawSlots) { + for (let index = 0; index < rawSlots.length; index += 1) { + const slot = rawSlots[index]; if ( typeof slot !== 'string' || slot.length === 0 || - new TextEncoder().encode(slot).byteLength > 256 || + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [slot]) as Uint8Array) + .byteLength > 256 || hasAsciiControl(slot) || - /[\uD800-\uDFFF]/u.test(slot) + reflectApplyIntrinsic(regExpTestIntrinsic, loneSurrogatePattern, [slot]) ) { throw new RequestAdsInputError('invalid_slots'); } diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index b94789eb5..1863078e4 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -87,7 +87,10 @@ function ownDataRecord(value: unknown): Record | undefined { if (prototype !== Object.prototype && prototype !== null) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; const output: Record = Object.create(null) as Record; - for (const key of Object.getOwnPropertyNames(value)) { + const names = Object.getOwnPropertyNames(value); + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + if (key === undefined) return undefined; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; Object.defineProperty(output, key, { @@ -171,7 +174,8 @@ function copyJsonRecord( const rootSnapshot = measurements?.get(value)?.snapshot ?? snapshotJsonContainer(value); if (!rootSnapshot || rootSnapshot.array) return undefined; const root: Record = {}; - const active = new Set([value]); + const active = new Set(); + active.add(value); const stack: JsonCloneFrame[] = [ { index: 0, output: root, snapshot: rootSnapshot, source: value }, ]; @@ -246,7 +250,8 @@ function copyJsonForSerialization(value: object): object | undefined { const rootSnapshot = snapshotJsonContainer(value); if (!rootSnapshot) return undefined; const root = safeSerializationContainer(rootSnapshot.array); - const active = new Set([value]); + const active = new Set(); + active.add(value); const completed = new WeakMap | unknown[]>(); const stack: JsonCloneFrame[] = [ { index: 0, output: root, snapshot: rootSnapshot, source: value }, @@ -357,7 +362,8 @@ function measureJson( if (completedRoot) return completedRoot; const root = snapshotJsonContainer(value); if (!root) return undefined; - const active = new Set([value]); + const active = new Set(); + active.add(value); const stack: JsonMeasureFrame[] = [ { array: root.array, @@ -491,7 +497,8 @@ export function prepareProgrammaticAdUnits( throw new AdUnitRegistrationError('invalid_media_types', index); } const sizes: Array = []; - for (const rawSize of rawSizes) { + for (let sizeIndex = 0; sizeIndex < rawSizes.length; sizeIndex += 1) { + const rawSize = rawSizes[sizeIndex]; const dimensions = ownDataArray(rawSize, 2); if ( !dimensions || @@ -517,7 +524,8 @@ export function prepareProgrammaticAdUnits( const rawBids = ownDataArray(unit.bids, MAX_JSON_STRUCTURE_ENTRIES); if (!rawBids) throw new AdUnitRegistrationError('invalid_bids', index); const pendingBids: PendingProgrammaticBid[] = []; - for (const rawBid of rawBids) { + for (let bidIndex = 0; bidIndex < rawBids.length; bidIndex += 1) { + const rawBid = rawBids[bidIndex]; const bid = ownDataRecord(rawBid); if (!bid || (!exactKeys(bid, ['bidder']) && !exactKeys(bid, ['bidder', 'params']))) { throw new AdUnitRegistrationError('invalid_bids', index); @@ -525,7 +533,11 @@ export function prepareProgrammaticAdUnits( if ( typeof bid.bidder !== 'string' || bid.bidder.length === 0 || - textEncoder.encode(bid.bidder).byteLength > 64 + ( + reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [ + bid.bidder, + ]) as Uint8Array + ).byteLength > 64 ) { throw new AdUnitRegistrationError('invalid_bidder', index); } @@ -639,7 +651,9 @@ export function serializeAuctionRequestBody( const legacyRegistry = new Map(); export function addAdUnits(units: AdUnit | AdUnit[]): void { - for (const unit of toArray(units)) { + const normalized = toArray(units); + for (let index = 0; index < normalized.length; index += 1) { + const unit = normalized[index]; if (!unit?.code) continue; legacyRegistry.set(unit.code, { ...legacyRegistry.get(unit.code), ...unit }); } diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index d129af03a..8df251f24 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -657,6 +657,54 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { } }); + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const valid = largeAdmProjection([16]); + const invalid = largeAdmProjection([16]); + invalid.bids[0]!.provider = '-invalid'; + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, iterator: 0, test: 0 }; + let parsed: BrowserAuctionProjectionV1 | undefined; + let rejected: BrowserAuctionProjectionV1 | undefined; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + value: () => { + calls.iterator += 1; + throw new Error('poisoned array iterator'); + }, + }); + Object.defineProperty(TextEncoder.prototype, 'encode', { + configurable: true, + value: () => { + calls.encode += 1; + throw new Error('poisoned text encoder'); + }, + }); + Object.defineProperty(RegExp.prototype, 'test', { + configurable: true, + value: () => { + calls.test += 1; + throw new Error('poisoned regular expression'); + }, + }); + try { + parsed = parseBrowserAuctionProjectionV1(valid); + rejected = parseBrowserAuctionProjectionV1(invalid); + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + if (encodeDescriptor) + Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); + if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + } + + expect(parsed).toBeDefined(); + expect(rejected).toBeUndefined(); + expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + }); + it('requires cache sources to match one frozen cache policy exactly', () => { const cacheId = 'f47447a0-b759-4f2f-9887-af458b79b570'; const policy = parseCacheFetchPolicyV1({ diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index b6ab072bc..68fcfa919 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -222,6 +222,58 @@ describe('registry', () => { expect(ownKeysCalls).toBeLessThanOrEqual(4); }); + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const validCandidate = unit('poison-safe'); + const invalidCandidate = { ...unit('invalid-bidder'), bids: [{ bidder: '' }] }; + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, iterator: 0, test: 0 }; + let prepared: ReturnType | undefined; + let invalidError: unknown; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + value: () => { + calls.iterator += 1; + throw new Error('poisoned array iterator'); + }, + }); + Object.defineProperty(TextEncoder.prototype, 'encode', { + configurable: true, + value: () => { + calls.encode += 1; + throw new Error('poisoned text encoder'); + }, + }); + Object.defineProperty(RegExp.prototype, 'test', { + configurable: true, + value: () => { + calls.test += 1; + throw new Error('poisoned regular expression'); + }, + }); + try { + prepared = prepareProgrammaticAdUnits(validCandidate, new Set()); + try { + prepareProgrammaticAdUnits(invalidCandidate, new Set()); + } catch (error) { + invalidError = error; + } + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + if (encodeDescriptor) + Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); + if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + } + + expect(prepared?.[0]?.code).toBe('poison-safe'); + expect(invalidError).toBeInstanceOf(AdUnitRegistrationError); + expect(invalidError).toMatchObject({ code: 'invalid_bidder', unitIndex: 0 }); + expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + }); + it('serializes detached auction data without invoking inherited toJSON hooks', () => { const prepared = prepareProgrammaticAdUnits(unit(), new Set()); const context = Object.freeze({ segments: Object.freeze(['one']) }); diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 48625dcf0..dc3d85c8f 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -93,6 +93,56 @@ describe('requestAds input contract', () => { }); expectInputError(() => validateRequestAdsOptions({ slots: ['slot\u007fid'] }), 'invalid_slots'); }); + + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { + const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); + const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); + const calls = { encode: 0, iterator: 0, test: 0 }; + let validated: ReturnType | undefined; + let duplicateError: unknown; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + value: () => { + calls.iterator += 1; + throw new Error('poisoned array iterator'); + }, + }); + Object.defineProperty(TextEncoder.prototype, 'encode', { + configurable: true, + value: () => { + calls.encode += 1; + throw new Error('poisoned text encoder'); + }, + }); + Object.defineProperty(RegExp.prototype, 'test', { + configurable: true, + value: () => { + calls.test += 1; + throw new Error('poisoned regular expression'); + }, + }); + try { + validated = validateRequestAdsOptions({ slots: ['slot-one'], timeoutMs: 100 }); + try { + validateRequestAdsOptions({ slots: ['slot-one', 'slot-one'] }); + } catch (error) { + duplicateError = error; + } + } finally { + if (iteratorDescriptor) { + Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + if (encodeDescriptor) + Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); + if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + } + + expect(validated).toMatchObject({ slots: ['slot-one'], timeoutMs: 100 }); + expect(duplicateError).toBeInstanceOf(RequestAdsInputError); + expect(duplicateError).toMatchObject({ code: 'duplicate_slot' }); + expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + }); }); describe('request.requestAds', () => { From 4f242d387f32260f6f8a84f37b0dcccf0af2a293 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:01:24 -0700 Subject: [PATCH 326/494] Classify shared DAG budget overflow --- .../lib/src/core/registry.ts | 4 +-- .../lib/test/core/registry.test.ts | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 1863078e4..476c56101 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -547,9 +547,7 @@ export function prepareProgrammaticAdUnits( throw new AdUnitRegistrationError('invalid_params', index); } const measurement = measureJson(bid.params, measurementMemo); - if (!measurement || measurement.structureEntries > MAX_JSON_STRUCTURE_ENTRIES) { - throw new AdUnitRegistrationError('invalid_params', index); - } + if (!measurement) throw new AdUnitRegistrationError('invalid_params', index); params = bid.params; } pendingBids.push( diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index 68fcfa919..b2c63c311 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -222,6 +222,35 @@ describe('registry', () => { expect(ownKeysCalls).toBeLessThanOrEqual(4); }); + it('charges acyclic shared-DAG multiplicity to the aggregate body budget', () => { + const descriptorReads: number[] = []; + let shared: object = { value: 'leaf' }; + for (let depth = 0; depth < 24; depth += 1) { + const node = { left: shared, right: shared }; + const nodeIndex = descriptorReads.length; + descriptorReads.push(0); + shared = new Proxy(node, { + getOwnPropertyDescriptor: (target, key) => { + descriptorReads[nodeIndex] = (descriptorReads[nodeIndex] ?? 0) + 1; + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + } + + expectRegistrationError( + () => + prepareProgrammaticAdUnits( + { + ...unit('shared-dag'), + bids: [{ bidder: 'fictional', params: shared }], + }, + new Set() + ), + 'request_body_too_large' + ); + expect(descriptorReads.every((reads) => reads <= 2)).toBe(true); + }); + it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const validCandidate = unit('poison-safe'); const invalidCandidate = { ...unit('invalid-bidder'), bids: [{ bidder: '' }] }; From 679671fca8870002952f106f24b6f3abdfdb7de6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:03:05 -0700 Subject: [PATCH 327/494] Publish GPT winners transactionally --- .../lib/src/composition/browser.ts | 36 +- .../lib/src/integrations/gpt/module.ts | 333 ++++++++++++++++++ .../lib/src/services/slots.ts | 28 ++ .../lib/test/composition/browser.test.ts | 75 ++-- .../lib/test/integrations/gpt/module.test.ts | 274 ++++++++++++++ .../lib/test/services/slots.test.ts | 26 ++ 6 files changed, 745 insertions(+), 27 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 705d52282..25d7c239f 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -34,7 +34,13 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; -import { startGptSlotOperation, type GptSlotOperationInput } from '../integrations/gpt/module'; +import { + publishGptWinner, + startGptSlotOperation, + type GptSlotOperationInput, + type GptWinnerPublicationInput, + type GptWinnerPublicationResult, +} from '../integrations/gpt/module'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -130,6 +136,13 @@ export interface BrowserRuntimeComposition extends BrowserComposition { readonly startGptSlotOperationForTest: ( input: Omit ) => SlotOperationCreationResult; + /** Publish one prospective server winner through the ordered GPT transaction in tests. */ + readonly publishGptWinnerForTest: ( + input: Omit< + GptWinnerPublicationInput, + 'googletag' | 'navigation' | 'pucBridge' | 'reservations' | 'slots' | 'targeting' + > + ) => Promise; } export interface BrowserCoreActivations { @@ -731,6 +744,27 @@ export function createTestBrowserRuntimeComposition( reservationServiceForTest: () => browserServices?.reservations, rendererNonceRegistryForTest: () => browserServices?.rendererNonces, pucBridgeForTest: () => browserServices?.pucBridge, + publishGptWinnerForTest: ( + input: Omit< + GptWinnerPublicationInput, + 'googletag' | 'navigation' | 'pucBridge' | 'reservations' | 'slots' | 'targeting' + > + ): Promise => { + const services = browserServices; + const navigation = runtimeSession?.currentNavigation; + if (!services || !navigation) { + return Promise.resolve(Object.freeze({ ok: false, reason: 'gpt_request_failed' })); + } + return publishGptWinner({ + ...input, + googletag: composition.adapters.googletag, + navigation, + pucBridge: services.pucBridge, + reservations: services.reservations, + slots: services.slots, + targeting: services.targeting, + }); + }, startGptSlotOperationForTest: ( input: Omit ): SlotOperationCreationResult => { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index e709c8003..92fc4d466 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -3,14 +3,29 @@ import type { IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; +import type { GoogletagAdapter, GoogletagFacade } from '../../adapters/googletag'; +import { + isAuctionCandidateIdV1, + isRendererReservationIdV1, +} from '../../core/contracts/auction_projection'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import type { NavigationSession } from '../../kernel/sessions'; import { createSlotOperation, + type CommittedRenderArtifact, type RenderAttempt, + type RenderFailureReason, type SlotOperationCreationResult, type SlotOperationOptions, } from '../../services/render'; import type { PucBridge, PucGamAttemptInput } from '../../services/puc_bridge'; +import type { ReservationService } from '../../services/reservations'; import type { SlotRequestOutcome, SlotService } from '../../services/slots'; +import type { + TargetingBoundary, + TargetingOwnership, + TargetingService, +} from '../../services/targeting'; import { installGptGuard, resetGuardState } from './script_guard'; @@ -40,6 +55,324 @@ export interface GptSlotOperationInput extends Omit; } +export type GptWinnerPublicationFailureReason = Extract< + RenderFailureReason, + | 'descriptor_invalid' + | 'gpt_request_failed' + | 'registry_full' + | 'reservation_collision' + | 'slot_unresolved' + | 'winner_not_renderable' +>; + +export type GptWinnerPublicationResult = + | Extract + | Readonly<{ ok: false; reason: GptWinnerPublicationFailureReason }>; + +export interface GptWinnerPublicationInput extends Omit< + GptSlotOperationInput, + 'artifact' | 'pucBridge' | 'reservationId' | 'slots' +> { + readonly artifact: CommittedRenderArtifact; + readonly bid: BrowserAuctionBidV1; + readonly googletag: GoogletagAdapter; + readonly navigation: NavigationSession; + readonly pucBridge: Pick; + readonly reservations: Pick; + readonly slot: object; + readonly slots: Pick; + readonly targeting: Pick; +} + +function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { + try { + const projection = input.navigation.currentAuctionProjection as + BrowserAuctionProjectionV1 | undefined; + const bid = input.bid; + if ( + !projection || + !Object.isFrozen(projection) || + !Object.isFrozen(bid) || + !Object.isFrozen(bid.renderSource) || + !Object.isFrozen(bid.targeting) || + !isAuctionCandidateIdV1(bid.candidateId) || + !isRendererReservationIdV1(bid.rendererReservationId) || + bid.slot !== input.attempt.slot || + input.attempt.navigationGeneration !== input.navigation.generation || + input.owner.id !== input.attempt.id || + input.owner.slot !== input.attempt.slot || + input.owner.generation !== input.attempt.generation || + input.owner.navigationGeneration !== input.navigation.generation || + input.artifact.kind !== 'puc' || + input.artifact.attemptId !== input.attempt.id || + input.artifact.slot !== input.attempt.slot || + input.artifact.navigationGeneration !== input.navigation.generation || + typeof input.artifact.dispose !== 'function' || + typeof input.slot !== 'object' || + input.slot === null || + !input.navigation.isCurrent() + ) { + return false; + } + let exactBid = false; + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === bid) { + if (exactBid) return false; + exactBid = true; + } + } + if (!exactBid) return false; + let exactWinner = false; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const result = projection.auction.results[index]; + if ( + result?.outcome === 'winner' && + result.slot === bid.slot && + result.candidateId === bid.candidateId + ) { + if (exactWinner) return false; + exactWinner = true; + } + } + return exactWinner; + } catch { + return false; + } +} + +function targetingEntries( + bid: BrowserAuctionBidV1 +): readonly (readonly [string, string])[] | undefined { + try { + const names = Object.getOwnPropertyNames(bid.targeting).sort(); + if (names.length > 32 || Object.getOwnPropertySymbols(bid.targeting).length !== 0) { + return undefined; + } + const entries: Array = [ + Object.freeze(['hb_adid', bid.rendererReservationId]), + ]; + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + if (!key || key === 'hb_adid') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(bid.targeting, key); + if ( + !descriptor || + !descriptor.enumerable || + !('value' in descriptor) || + typeof descriptor.value !== 'string' + ) { + return undefined; + } + entries[entries.length] = Object.freeze([key, descriptor.value]); + } + return Object.freeze(entries); + } catch { + return undefined; + } +} + +function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): TargetingBoundary { + const invoke = (command: (gpt: Readonly) => Value): Value => { + let completed = false; + let value: Value | undefined; + let failure: unknown; + const operation = adapter.run((gpt) => { + try { + value = command(gpt); + return value; + } catch (error) { + failure = error; + throw error; + } finally { + completed = true; + } + }); + void operation.result.catch(() => undefined); + if (!completed) { + operation.dispose(); + throw new Error('GPT targeting operation is not synchronously available'); + } + if (failure !== undefined) throw failure; + return value as Value; + }; + return Object.freeze({ + clearTargeting: (key?: string) => invoke((gpt) => gpt.clearTargeting(slot, key)), + getTargeting: (key: string) => invoke((gpt) => gpt.getTargeting(slot, key)), + setTargeting: (key: string, value: string | readonly string[]) => + invoke((gpt) => gpt.setTargeting(slot, key, value)), + }); +} + +function reservationFailure(reason: string): GptWinnerPublicationFailureReason { + if (reason === 'reservation_collision') return 'reservation_collision'; + if (reason === 'registry_full') return 'registry_full'; + if (reason === 'invalid_render_source' || reason === 'invalid_reservation_id') { + return 'descriptor_invalid'; + } + return 'gpt_request_failed'; +} + +/** Publish one server-projected PUC winner without exposing capability state out of order. */ +export async function publishGptWinner( + input: GptWinnerPublicationInput +): Promise { + const failAttempt = (reason: GptWinnerPublicationFailureReason): GptWinnerPublicationResult => { + try { + input.attempt.fail(reason); + } catch { + // The attempt latch remains authoritative. + } + return Object.freeze({ ok: false, reason }); + }; + const disposeArtifact = (): void => { + try { + input.artifact.dispose(); + } catch { + // Rejected publication retains no artifact authority. + } + }; + if (!currentProjectedWinner(input)) { + disposeArtifact(); + return failAttempt('winner_not_renderable'); + } + const bound = (() => { + try { + return input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.slot); + } catch { + return false; + } + })(); + if (!bound) { + disposeArtifact(); + return failAttempt('slot_unresolved'); + } + const entries = targetingEntries(input.bid); + if (!entries) { + disposeArtifact(); + return failAttempt('descriptor_invalid'); + } + const winnerContext = Object.freeze({ selectedCpm: input.bid.cpm }); + const registration = (() => { + try { + return input.reservations.registerRender({ + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigation: input.navigation, + attemptId: input.attempt.id, + renderSource: input.bid.renderSource, + winnerContext, + }); + } catch { + return Object.freeze({ ok: false as const, reason: 'service_disposed' as const }); + } + })(); + if (!registration.ok) { + disposeArtifact(); + return failAttempt(reservationFailure(registration.reason)); + } + + const owners: TargetingOwnership[] = []; + let observation: ReturnType | undefined; + let resourcesDisposed = false; + const disposeResources = (): void => { + if (resourcesDisposed) return; + resourcesDisposed = true; + for (let index = owners.length - 1; index >= 0; index -= 1) { + try { + owners[index]?.release(); + } catch { + // One targeting cleanup cannot suppress the remaining rollback. + } + } + try { + observation?.dispose(); + } catch { + // The adapter owns final wrapper restoration. + } + disposeArtifact(); + }; + const tombstone = (): void => { + try { + input.reservations.tombstone( + { + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigationGeneration: input.navigation.generation, + attemptId: input.attempt.id, + }, + 'disposed' + ); + } catch { + // The failed publication is already terminal and cannot expose the id again. + } + }; + try { + observation = input.targeting.observePublisherMutations(input.slot, input.googletag); + await observation.result; + if (!input.navigation.isCurrent() || input.attempt.snapshot().outcome !== undefined) { + throw new Error('stale GPT publication'); + } + const boundary = synchronousTargetingBoundary(input.googletag, input.slot); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry) throw new Error('targeting entry unavailable'); + const owner = input.targeting.own(input.slot, entry[0], entry[1], input.attempt.id, boundary); + if (!owner) throw new Error('targeting ownership unavailable'); + owners[owners.length] = owner; + } + } catch { + tombstone(); + disposeResources(); + return failAttempt('gpt_request_failed'); + } + + const publishedArtifact = Object.freeze({ + kind: 'puc' as const, + attemptId: input.artifact.attemptId, + slot: input.artifact.slot, + navigationGeneration: input.artifact.navigationGeneration, + dispose: disposeResources, + }); + let bridgeRegistered = false; + let requestStarted = false; + let operation: SlotOperationCreationResult; + try { + operation = startGptSlotOperation({ + artifact: publishedArtifact, + attempt: input.attempt, + ...(input.createFallback === undefined ? {} : { createFallback: input.createFallback }), + operation: input.operation, + owner: input.owner, + pucBridge: { + registerGamAttempt: (bridgeInput) => { + bridgeRegistered = input.pucBridge.registerGamAttempt(bridgeInput) === true; + return bridgeRegistered; + }, + recordNonemptyGam: (bridgeInput) => input.pucBridge.recordNonemptyGam(bridgeInput), + }, + requestClass: input.requestClass, + reservationId: input.bid.rendererReservationId, + slots: { + request: (requestInput) => { + const handle = input.slots.request(requestInput); + requestStarted = true; + return handle; + }, + }, + }); + } catch { + tombstone(); + disposeResources(); + return failAttempt('gpt_request_failed'); + } + if (!operation.ok || !bridgeRegistered || !requestStarted) { + tombstone(); + disposeResources(); + return failAttempt('gpt_request_failed'); + } + return operation; +} + function settleFromSlotOutcome( attempt: RenderAttempt, bridge: GptSlotOperationInput['pucBridge'], diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 05abfd982..7c59f56fb 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -131,6 +131,11 @@ export interface SlotService { ) => GptSlotAdoptionResult; readonly dispose: () => void; readonly handleGptEvent: (type: GptEventType, event: unknown) => void; + readonly isBoundGptSlot: ( + navigationGeneration: object, + registeredSlotId: string, + slot: object + ) => boolean; readonly prepareProjectionSlots: ( owner: NavigationSession, slots: readonly string[] @@ -2487,6 +2492,29 @@ export function createSlotService(options: SlotServiceOptions): SlotService { activation?.dispose(); }, handleGptEvent, + isBoundGptSlot: ( + navigationGeneration: object, + registeredSlotId: string, + slot: object + ): boolean => { + try { + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + return ( + !!state && + !state.disposed && + state.owner.isCurrent() && + !!physical && + physical.slot === slot && + physical.record === record && + physical.ownership === 'trusted_server' && + physical.state === 'live' + ); + } catch { + return false; + } + }, prepareProjectionSlots: ( owner: NavigationSession, slots: readonly string[] diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index fd7746ae5..0a7af0c7b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -22,6 +22,7 @@ import { createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; +import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { publicLog } from '../../src/kernel/fallback'; @@ -51,18 +52,29 @@ function fakeGoogletagAdapter( function synchronousGptAdapter() { const listeners = new Map void>>(); + const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); const refresh = vi.fn(); const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, - clearTargeting: vi.fn(), + clearTargeting: vi.fn((slot: object, key?: string) => { + const values = targeting.get(slot); + if (key === undefined) values?.clear(); + else values?.delete(key); + }), display: vi.fn(), - getTargeting: vi.fn(() => []), + getTargeting: vi.fn((slot: object, key: string) => + Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) + ), observeTargeting: () => vi.fn(), refresh, serviceState: () => Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), - setTargeting: vi.fn(), + setTargeting: vi.fn((slot: object, key: string, value: string | readonly string[]) => { + const values = targeting.get(slot) ?? new Map(); + targeting.set(slot, values); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), slots: () => Object.freeze([]), subscribe: (eventType: string, listener: (event: unknown) => void) => { const registered = listeners.get(eventType) ?? new Set(); @@ -172,14 +184,39 @@ describe('browser composition', () => { it('routes an attributable empty GPT cycle through the owned slot and PUC services', async () => { const gpt = synchronousGptAdapter(); let prefix = 0; + const reservationId = `r1_${'a'.repeat(22)}`; + const source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
fictional fallback
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted' }), + rendererReservationId: reservationId, + renderSource: source, + }); const projection = Object.freeze({ version: 1, auction: Object.freeze({ version: 1, auctionId: 'initial', - results: Object.freeze([Object.freeze({ slot: 'slot-one', outcome: 'no_bid' as const })]), + results: Object.freeze([ + Object.freeze({ + slot: 'slot-one', + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), }), - bids: Object.freeze([]), + bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( { @@ -243,13 +280,6 @@ describe('browser composition', () => { }; const ownerResult = batch.createRenderAttempt('slot-one'); if (!ownerResult.ok) throw new Error(ownerResult.reason); - const source = Object.freeze({ - type: 'adm' as const, - version: 1 as const, - adm: '
fictional fallback
', - width: 300, - height: 250, - }); const primaryResult = createRenderAttempt({ artifacts: artifacts as Parameters[0]['artifacts'], owner: ownerResult.value, @@ -258,18 +288,6 @@ describe('browser composition', () => { }); if (!primaryResult.ok) throw new Error(primaryResult.reason); const primary = primaryResult.value; - const reservationId = `r1_${'a'.repeat(22)}`; - const winnerContext = Object.freeze({ selectedCpm: 1 }); - expect( - reservations.registerRender({ - reservationId, - slot: primary.slot, - navigation, - attemptId: primary.id, - renderSource: source, - winnerContext, - }) - ).toMatchObject({ ok: true }); const physicalSlot = Object.freeze({}); const slotElement = document.createElement('div'); slotElement.id = 'slot-one'; @@ -292,10 +310,15 @@ describe('browser composition', () => { navigationGeneration: primary.navigationGeneration, dispose: vi.fn(), }) satisfies CommittedRenderArtifact; + const projectedBid = ( + navigation.currentAuctionProjection as Readonly<{ bids: readonly BrowserAuctionBidV1[] }> + ).bids[0]; + if (!projectedBid) throw new Error('Expected the parsed projected winner'); let fallback: RenderAttempt | undefined; - const operation = composition.startGptSlotOperationForTest({ + const operation = await composition.publishGptWinnerForTest({ artifact, attempt: primary, + bid: projectedBid, createFallback: (parentAttemptId) => { fallback = createAttempt(parentAttemptId); return Object.freeze({ ok: true as const, value: fallback }); @@ -303,7 +326,7 @@ describe('browser composition', () => { operation: 'refresh', owner: ownerResult.value, requestClass: 'primary', - reservationId, + slot: physicalSlot, }); expect(operation.ok).toBe(true); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 234bd74ad..91c462822 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -2,9 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGptIntegrationRegistration, + publishGptWinner, startGptSlotOperation, + type GptWinnerPublicationInput, type GptSlotOperationInput, } from '../../../src/integrations/gpt/module'; +import { createNoopGoogletagAdapter, type GoogletagFacade } from '../../../src/adapters/googletag'; import { isGuardInstalled, resetGuardState } from '../../../src/integrations/gpt/script_guard'; import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; import { @@ -21,6 +24,7 @@ import { } from '../../../src/services/render'; import { createReservationService } from '../../../src/services/reservations'; import type { SlotRequestOutcome } from '../../../src/services/slots'; +import { createTargetingService } from '../../../src/services/targeting'; const RELEASE_ID = 'a'.repeat(64); const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -83,8 +87,10 @@ function createAttemptHarness() { artifact, createAttempt: (parentAttemptId: string): RenderAttempt => createAttemptWithOwner(parentAttemptId).attempt, + navigation: navigationResult.value, primary, primaryOwner: primaryCreated.owner, + reservations, runtime, }; } @@ -452,3 +458,271 @@ describe('transactional GPT integration module', () => { } ); }); + +describe('ordered GPT winner publication', () => { + function preparePublication() { + const harness = createAttemptHarness(); + const source = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
trusted
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: harness.primary.slot, + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted' }), + rendererReservationId: RESERVATION_ID, + renderSource: source, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'gpt-publication', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + expect(harness.navigation.installAuctionProjection(projection)).toBe(true); + + const order: string[] = []; + const values = new Map(); + const slot = Object.freeze({ + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + order.push(`target:${key}`); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }); + const facade: GoogletagFacade = Object.freeze({ + bindingToken: () => Object.freeze({}), + clearTargeting: (target: object, key?: string) => (target as typeof slot).clearTargeting(key), + display: vi.fn(), + getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), + observeTargeting: () => { + order.push('observe'); + return vi.fn(); + }, + refresh: vi.fn(), + serviceState: () => + Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), + setTargeting: (target: object, key: string, value: string | readonly string[]) => + (target as typeof slot).setTargeting(key, value), + slots: () => Object.freeze([slot]), + subscribe: () => vi.fn(), + transactionalReplace: () => Object.freeze({ status: 'destroyed' as const }), + }); + const googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + bindingStatus: () => 'present' as const, + run: (command: (gpt: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }); + const targeting = createTargetingService(); + const slotOutcome = deferredSlotOutcome(); + const slots = { + isBoundGptSlot: vi.fn(() => { + order.push('slot:validate'); + return true; + }), + request: vi.fn((input: unknown) => { + order.push('request'); + expect(input).toMatchObject({ registeredSlotId: bid.slot }); + expect(harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'renderable', + }); + return slotOutcome.request(); + }), + }; + let bridgeArtifact: CommittedRenderArtifact | undefined; + const pucBridge = { + registerGamAttempt: vi.fn((input: GptSlotOperationInput) => { + order.push('bridge'); + bridgeArtifact = input.artifact; + return input.attempt.beginGamClaim(); + }), + recordNonemptyGam: vi.fn(() => true), + }; + const reservations = { + registerRender: vi.fn((input: Parameters[0]) => { + order.push('reservation'); + return harness.reservations.registerRender(input); + }), + tombstone: harness.reservations.tombstone, + }; + const input: GptWinnerPublicationInput = { + artifact: harness.artifact, + attempt: harness.primary, + bid, + googletag, + navigation: harness.navigation, + operation: 'refresh', + owner: harness.primaryOwner, + pucBridge, + requestClass: 'primary', + reservations, + slot, + slots, + targeting, + }; + return { + bid, + bridgeArtifact: () => bridgeArtifact, + harness, + input, + order, + pucBridge, + reservations, + slot, + slots, + targeting, + values, + }; + } + + it('publishes reservation, targeting, intent, and request in that exact order', async () => { + const publication = preparePublication(); + + const result = await publishGptWinner(publication.input); + + expect(result.ok).toBe(true); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'target:hb_adid', + 'target:hb_bidder', + 'bridge', + 'request', + ]); + expect(publication.values).toEqual( + new Map([ + ['hb_adid', [RESERVATION_ID]], + ['hb_bidder', ['trusted']], + ]) + ); + publication.bridgeArtifact()?.dispose(); + expect(publication.values.size).toBe(0); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('rolls back targeting and tombstones when the bridge refuses before request', async () => { + const publication = preparePublication(); + publication.pucBridge.registerGamAttempt.mockImplementation(() => { + publication.order.push('bridge'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('rolls back targeting and tombstones when the slot request throws', async () => { + const publication = preparePublication(); + publication.slots.request.mockImplementation(() => { + publication.order.push('request'); + throw new Error('fictional request failure'); + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'target:hb_adid', + 'target:hb_bidder', + 'bridge', + 'request', + ]); + expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('fails before exposure when reservation insertion collides', async () => { + const publication = preparePublication(); + expect( + publication.harness.reservations.registerRender({ + reservationId: RESERVATION_ID, + slot: publication.bid.slot, + navigation: publication.harness.navigation, + attemptId: publication.harness.primary.id, + renderSource: publication.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: publication.bid.cpm }), + }) + ).toMatchObject({ ok: true }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(publication.order).toEqual(['slot:validate', 'reservation']); + expect(publication.values.size).toBe(0); + expect(publication.pucBridge.registerGamAttempt).not.toHaveBeenCalled(); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('compare-restores earlier targeting when a later targeting write throws', async () => { + const publication = preparePublication(); + publication.slot.setTargeting.mockImplementation((key, value) => { + publication.order.push(`target:${key}`); + if (key === 'hb_bidder') throw new Error('fictional targeting failure'); + publication.values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'gpt_request_failed', + }); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + publication.harness.runtime.dispose(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 4d45887f8..9acc2d007 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -425,6 +425,32 @@ describe('slot registry', () => { expect(service.resolveRegisteredSlot('one')).toBeUndefined(); }); + it('recognizes only the exact live Trusted Server GPT binding', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const trustedSlot = bindTrustedSlot(service, navigation, 'trusted'); + + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(true); + expect(service.isBoundGptSlot(navigation.generation, 'other', trustedSlot)).toBe(false); + expect(service.isBoundGptSlot({}, 'trusted', trustedSlot)).toBe(false); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', {})).toBe(false); + + const publisherSlot = {}; + expect(service.register(navigation, [serverRegistration('publisher')])).toMatchObject({ + ok: true, + }); + expect( + service.adoptGptSlot(navigation.generation, 'publisher', { + ownership: 'publisher', + slot: publisherSlot, + }) + ).toEqual({ ok: true }); + expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(false); + + runtime.dispose(); + expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); + }); + it('uses captured Set validation intrinsics on a hostile page', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); From d8f98b00111c5757422d839a396bcafc93bf60e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:05:52 -0700 Subject: [PATCH 328/494] Revalidate GPT winner publication ownership --- .../lib/src/integrations/gpt/module.ts | 80 ++++++++++++------- .../lib/test/integrations/gpt/module.test.ts | 73 +++++++++++++++++ 2 files changed, 124 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 92fc4d466..23c4ace54 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -91,10 +91,10 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { const bid = input.bid; if ( !projection || - !Object.isFrozen(projection) || - !Object.isFrozen(bid) || - !Object.isFrozen(bid.renderSource) || - !Object.isFrozen(bid.targeting) || + !objectIsFrozenIntrinsic(projection) || + !objectIsFrozenIntrinsic(bid) || + !objectIsFrozenIntrinsic(bid.renderSource) || + !objectIsFrozenIntrinsic(bid.targeting) || !isAuctionCandidateIdV1(bid.candidateId) || !isRendererReservationIdV1(bid.rendererReservationId) || bid.slot !== input.attempt.slot || @@ -144,8 +144,19 @@ function targetingEntries( bid: BrowserAuctionBidV1 ): readonly (readonly [string, string])[] | undefined { try { - const names = Object.getOwnPropertyNames(bid.targeting).sort(); - if (names.length > 32 || Object.getOwnPropertySymbols(bid.targeting).length !== 0) { + const unsortedNames = objectGetOwnPropertyNamesIntrinsic(bid.targeting); + const names: string[] = []; + for (let index = 0; index < unsortedNames.length; index += 1) { + const name = unsortedNames[index]; + if (name === undefined) return undefined; + let insertion = names.length; + while (insertion > 0 && (names[insertion - 1] as string) > name) insertion -= 1; + for (let move = names.length; move > insertion; move -= 1) { + names[move] = names[move - 1] as string; + } + names[insertion] = name; + } + if (names.length > 32 || objectGetOwnPropertySymbolsIntrinsic(bid.targeting).length !== 0) { return undefined; } const entries: Array = [ @@ -154,7 +165,7 @@ function targetingEntries( for (let index = 0; index < names.length; index += 1) { const key = names[index]; if (!key || key === 'hb_adid') return undefined; - const descriptor = Object.getOwnPropertyDescriptor(bid.targeting, key); + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); if ( !descriptor || !descriptor.enumerable || @@ -174,6 +185,7 @@ function targetingEntries( function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): TargetingBoundary { const invoke = (command: (gpt: Readonly) => Value): Value => { let completed = false; + let failed = false; let value: Value | undefined; let failure: unknown; const operation = adapter.run((gpt) => { @@ -181,6 +193,7 @@ function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): value = command(gpt); return value; } catch (error) { + failed = true; failure = error; throw error; } finally { @@ -192,7 +205,7 @@ function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): operation.dispose(); throw new Error('GPT targeting operation is not synchronously available'); } - if (failure !== undefined) throw failure; + if (failed) throw failure; return value as Value; }; return Object.freeze({ @@ -235,14 +248,14 @@ export async function publishGptWinner( disposeArtifact(); return failAttempt('winner_not_renderable'); } - const bound = (() => { + const isStillBound = (): boolean => { try { return input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.slot); } catch { return false; } - })(); - if (!bound) { + }; + if (!isStillBound()) { disposeArtifact(); return failAttempt('slot_unresolved'); } @@ -273,10 +286,29 @@ export async function publishGptWinner( const owners: TargetingOwnership[] = []; let observation: ReturnType | undefined; + let retirementAttempted = false; let resourcesDisposed = false; + const tombstone = (): void => { + if (retirementAttempted) return; + retirementAttempted = true; + try { + input.reservations.tombstone( + { + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigationGeneration: input.navigation.generation, + attemptId: input.attempt.id, + }, + 'disposed' + ); + } catch { + // Runtime disposal retains the last-resort retirement boundary. + } + }; const disposeResources = (): void => { if (resourcesDisposed) return; resourcesDisposed = true; + tombstone(); for (let index = owners.length - 1; index >= 0; index -= 1) { try { owners[index]?.release(); @@ -291,27 +323,16 @@ export async function publishGptWinner( } disposeArtifact(); }; - const tombstone = (): void => { - try { - input.reservations.tombstone( - { - reservationId: input.bid.rendererReservationId, - slot: input.bid.slot, - navigationGeneration: input.navigation.generation, - attemptId: input.attempt.id, - }, - 'disposed' - ); - } catch { - // The failed publication is already terminal and cannot expose the id again. - } - }; try { observation = input.targeting.observePublisherMutations(input.slot, input.googletag); await observation.result; if (!input.navigation.isCurrent() || input.attempt.snapshot().outcome !== undefined) { throw new Error('stale GPT publication'); } + if (!isStillBound()) { + disposeResources(); + return failAttempt('slot_unresolved'); + } const boundary = synchronousTargetingBoundary(input.googletag, input.slot); for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; @@ -320,8 +341,11 @@ export async function publishGptWinner( if (!owner) throw new Error('targeting ownership unavailable'); owners[owners.length] = owner; } + if (!isStillBound()) { + disposeResources(); + return failAttempt('slot_unresolved'); + } } catch { - tombstone(); disposeResources(); return failAttempt('gpt_request_failed'); } @@ -361,12 +385,10 @@ export async function publishGptWinner( }, }); } catch { - tombstone(); disposeResources(); return failAttempt('gpt_request_failed'); } if (!operation.ok || !bridgeRegistered || !requestStarted) { - tombstone(); disposeResources(); return failAttempt('gpt_request_failed'); } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 91c462822..878a3a4ac 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -614,8 +614,10 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'reservation', 'observe', + 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'slot:validate', 'bridge', 'request', ]); @@ -627,10 +629,79 @@ describe('ordered GPT winner publication', () => { ); publication.bridgeArtifact()?.dispose(); expect(publication.values.size).toBe(0); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); publication.harness.runtime.dispose(); }); + it('fails before targeting when exact slot ownership is lost across observation', async () => { + const publication = preparePublication(); + publication.slots.isBoundGptSlot + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementation(() => { + publication.order.push('slot:validate'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'slot_unresolved', + }); + expect(publication.order).toEqual(['slot:validate', 'reservation', 'observe', 'slot:validate']); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + expect(publication.harness.artifact.dispose).toHaveBeenCalledTimes(1); + publication.harness.runtime.dispose(); + }); + + it('compare-restores targeting when exact slot ownership is lost during writes', async () => { + const publication = preparePublication(); + publication.slots.isBoundGptSlot + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementationOnce(() => { + publication.order.push('slot:validate'); + return true; + }) + .mockImplementation(() => { + publication.order.push('slot:validate'); + return false; + }); + + await expect(publishGptWinner(publication.input)).resolves.toEqual({ + ok: false, + reason: 'slot_unresolved', + }); + expect(publication.order).toEqual([ + 'slot:validate', + 'reservation', + 'observe', + 'slot:validate', + 'target:hb_adid', + 'target:hb_bidder', + 'slot:validate', + ]); + expect(publication.values.size).toBe(0); + expect(publication.slots.request).not.toHaveBeenCalled(); + expect(publication.harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ + recognized: true, + state: 'disposed', + }); + publication.harness.runtime.dispose(); + }); + it('rolls back targeting and tombstones when the bridge refuses before request', async () => { const publication = preparePublication(); publication.pucBridge.registerGamAttempt.mockImplementation(() => { @@ -667,8 +738,10 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'reservation', 'observe', + 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'slot:validate', 'bridge', 'request', ]); From 615352f6d6cc35850a6063c026767df92fd3c777 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:08:01 -0700 Subject: [PATCH 329/494] Latch GPT publication to physical slot --- .../lib/src/integrations/gpt/module.ts | 15 ++++++-- .../lib/src/services/slots.ts | 7 +++- .../lib/test/services/slots.test.ts | 36 +++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 23c4ace54..4b1bfa27e 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -41,6 +41,8 @@ const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; const objectGetOwnPropertySymbolsIntrinsic = Object.getOwnPropertySymbols; const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; const objectIsFrozenIntrinsic = Object.isFrozen; +const promiseThenIntrinsic = Promise.prototype.then; +const reflectApplyIntrinsic = Reflect.apply; interface GptIntegrationRuntime { readonly start: (config: unknown) => void; @@ -110,7 +112,8 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { typeof input.artifact.dispose !== 'function' || typeof input.slot !== 'object' || input.slot === null || - !input.navigation.isCurrent() + !input.navigation.isCurrent() || + input.attempt.snapshot().outcome !== undefined ) { return false; } @@ -200,7 +203,10 @@ function synchronousTargetingBoundary(adapter: GoogletagAdapter, slot: object): completed = true; } }); - void operation.result.catch(() => undefined); + void reflectApplyIntrinsic(promiseThenIntrinsic, operation.result, [ + () => undefined, + () => undefined, + ]); if (!completed) { operation.dispose(); throw new Error('GPT targeting operation is not synchronously available'); @@ -341,6 +347,9 @@ export async function publishGptWinner( if (!owner) throw new Error('targeting ownership unavailable'); owners[owners.length] = owner; } + if (!input.navigation.isCurrent() || input.attempt.snapshot().outcome !== undefined) { + throw new Error('stale GPT publication'); + } if (!isStillBound()) { disposeResources(); return failAttempt('slot_unresolved'); @@ -378,7 +387,7 @@ export async function publishGptWinner( reservationId: input.bid.rendererReservationId, slots: { request: (requestInput) => { - const handle = input.slots.request(requestInput); + const handle = input.slots.request({ ...requestInput, expectedSlot: input.slot }); requestStarted = true; return handle; }, diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 7c59f56fb..743aa6eee 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -95,6 +95,8 @@ export type SlotRequestOutcome = | Readonly<{ status: 'cancelled'; reason: 'navigation_disposed' | 'superseded' }>; export interface SlotRequestInput { + /** Exact physical identity latch for a cross-service publication transaction. */ + readonly expectedSlot?: object; readonly intentId: string; readonly navigationGeneration: object; readonly operation: 'display' | 'refresh'; @@ -2110,6 +2112,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { settle(intent, failed('slot_unresolved')); return handle; } + if (input.expectedSlot !== undefined && input.expectedSlot !== physical.slot) { + settle(intent, failed('slot_unresolved')); + return handle; + } if (physical.state === 'retired' || physical.quarantineReason === 'request') { settle(intent, failed('gpt_request_failed')); return handle; @@ -2508,7 +2514,6 @@ export function createSlotService(options: SlotServiceOptions): SlotService { !!physical && physical.slot === slot && physical.record === record && - physical.ownership === 'trusted_server' && physical.state === 'live' ); } catch { diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 9acc2d007..b4a583829 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -425,7 +425,39 @@ describe('slot registry', () => { expect(service.resolveRegisteredSlot('one')).toBeUndefined(); }); - it('recognizes only the exact live Trusted Server GPT binding', () => { + it('latches a publication request to the exact bound GPT identity', async () => { + const gpt = createGptHarness(); + const service = createSlotService({ googletag: gpt.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const stale = service.request({ + expectedSlot: {}, + intentId: 'stale-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(stale.result).resolves.toEqual({ status: 'failed', reason: 'slot_unresolved' }); + expect(gpt.display).not.toHaveBeenCalled(); + + const current = service.request({ + expectedSlot: slot, + intentId: 'current-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + expect(current.status).toBe('active'); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(slot); + }); + + it('recognizes the exact live GPT binding regardless of who defined the slot', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const { navigation, runtime } = createRuntimeWithNavigation(); const trustedSlot = bindTrustedSlot(service, navigation, 'trusted'); @@ -445,7 +477,7 @@ describe('slot registry', () => { slot: publisherSlot, }) ).toEqual({ ok: true }); - expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(false); + expect(service.isBoundGptSlot(navigation.generation, 'publisher', publisherSlot)).toBe(true); runtime.dispose(); expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); From f3984e585573eb8629d195bad85ba2955b041b6d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:11:30 -0700 Subject: [PATCH 330/494] Harden exact-key validation helpers --- .../src/core/contracts/auction_projection.ts | 35 ++++++++++++++----- .../lib/src/core/contracts/request_ads.ts | 19 ++++++---- .../lib/src/core/registry.ts | 30 +++++++++++++--- .../lib/test/core/auction.test.ts | 26 +++++++++++--- .../lib/test/core/registry.test.ts | 27 +++++++++++--- .../lib/test/core/request.test.ts | 33 +++++++++++++---- 6 files changed, 135 insertions(+), 35 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts index 25b7fd702..b2a8bfbd1 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -20,6 +20,8 @@ const MAX_TARGETING_ENTRIES = 32; const MAX_ADM_BYTES = 512 * 1024; const MAX_URL_BYTES = 4096; const reflectApplyIntrinsic = Reflect.apply; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectKeysIntrinsic = Object.keys; const textEncoder = new TextEncoder(); const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const regExpTestIntrinsic = RegExp.prototype.test; @@ -42,6 +44,13 @@ const auctionFailureReasons = new Set([ 'internal_error', ]); +function hasString(values: readonly string[], expected: string): boolean { + for (let index = 0; index < values.length; index += 1) { + if (values[index] === expected) return true; + } + return false; +} + export function ownDataObject( value: unknown, expectedKeys?: readonly string[] @@ -50,12 +59,15 @@ export function ownDataObject( if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const names = Object.getOwnPropertyNames(value); - if ( - expectedKeys && - (names.length !== expectedKeys.length || expectedKeys.some((key) => !names.includes(key))) - ) { - return undefined; + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; + if (expectedKeys) { + if (names.length !== expectedKeys.length) return undefined; + for (let index = 0; index < expectedKeys.length; index += 1) { + const expected = expectedKeys[index]; + if (expected === undefined || !hasString(names, expected)) return undefined; + } } const snapshot: Record = Object.create(null) as Record; for (let index = 0; index < names.length; index += 1) { @@ -76,8 +88,10 @@ export function ownDataArray(value: unknown, maximum: number): unknown[] | undef if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; if (value.length > maximum || Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes('length')) return undefined; + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; + if (names.length !== value.length + 1 || !hasString(names, 'length')) return undefined; const snapshot: unknown[] = []; for (let index = 0; index < value.length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); @@ -211,7 +225,10 @@ function snapshotJsonForMeasurement(value: object): JsonMeasureSnapshot | undefi array, entries: array ? values!.map((entry, index) => ({ key: String(index), value: entry })) - : Object.keys(record!).map((key) => ({ key, value: record![key] })), + : (reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]).map((key) => ({ + key, + value: record![key], + })), }; } diff --git a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts index 470a99bb3..0258dbe14 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/request_ads.ts @@ -1,6 +1,8 @@ const REQUEST_ADS_DEFAULT_TIMEOUT_MS = 10_000; const REQUEST_ADS_MAX_SLOTS = 256; const reflectApplyIntrinsic = Reflect.apply; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectKeysIntrinsic = Object.keys; const textEncoder = new TextEncoder(); const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const regExpTestIntrinsic = RegExp.prototype.test; @@ -42,7 +44,9 @@ function ownDataOptions(value: unknown): Record | undefined { if (prototype !== Object.prototype && prototype !== null) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; const output: Record = Object.create(null) as Record; - const names = Object.getOwnPropertyNames(value); + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; for (let index = 0; index < names.length; index += 1) { const key = names[index]; if (key === undefined) return undefined; @@ -72,7 +76,9 @@ function ownDataSlots(value: unknown): readonly unknown[] | undefined { !Number.isSafeInteger(length.value) || length.value < 0 || length.value > REQUEST_ADS_MAX_SLOTS || - Object.getOwnPropertyNames(value).length !== length.value + 1 + (reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]) + .length !== + length.value + 1 ) { return undefined; } @@ -117,10 +123,11 @@ export function validateRequestAdsOptions(value: unknown): ValidatedRequestAdsOp }); } const options = ownDataOptions(value); - if ( - !options || - !Object.keys(options).every((key) => key === 'slots' || key === 'timeoutMs' || key === 'signal') - ) { + if (!options) throw new RequestAdsInputError('invalid_options'); + const optionKeys = reflectApplyIntrinsic(objectKeysIntrinsic, Object, [options]) as string[]; + for (let index = 0; index < optionKeys.length; index += 1) { + const key = optionKeys[index]; + if (key === 'slots' || key === 'timeoutMs' || key === 'signal') continue; throw new RequestAdsInputError('invalid_options'); } diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 476c56101..3a512eb48 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -12,6 +12,8 @@ const textEncoder = new TextEncoder(); const reflectApplyIntrinsic = Reflect.apply; const jsonStringifyIntrinsic = JSON.stringify; const objectCreateIntrinsic = Object.create; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectKeysIntrinsic = Object.keys; const objectSetPrototypeOfIntrinsic = Object.setPrototypeOf; const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; @@ -87,7 +89,9 @@ function ownDataRecord(value: unknown): Record | undefined { if (prototype !== Object.prototype && prototype !== null) return undefined; if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; const output: Record = Object.create(null) as Record; - const names = Object.getOwnPropertyNames(value); + const names = reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [ + value, + ]) as string[]; for (let index = 0; index < names.length; index += 1) { const key = names[index]; if (key === undefined) return undefined; @@ -122,7 +126,9 @@ function ownDataArray(value: unknown, maximum: number): readonly unknown[] | und !Number.isSafeInteger(length.value) || length.value < 0 || length.value > maximum || - Object.getOwnPropertyNames(value).length !== length.value + 1 + (reflectApplyIntrinsic(objectGetOwnPropertyNamesIntrinsic, Object, [value]) as string[]) + .length !== + length.value + 1 ) { return undefined; } @@ -139,8 +145,20 @@ function ownDataArray(value: unknown, maximum: number): readonly unknown[] | und } function exactKeys(record: Record, keys: readonly string[]): boolean { - const actual = Object.keys(record); - return actual.length === keys.length && actual.every((key) => keys.includes(key)); + const actual = reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]; + if (actual.length !== keys.length) return false; + for (let actualIndex = 0; actualIndex < actual.length; actualIndex += 1) { + const actualKey = actual[actualIndex]; + let found = false; + for (let expectedIndex = 0; expectedIndex < keys.length; expectedIndex += 1) { + if (keys[expectedIndex] === actualKey) { + found = true; + break; + } + } + if (!found) return false; + } + return true; } function jsonPrimitive(value: unknown): null | boolean | number | string | undefined { @@ -156,7 +174,9 @@ function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined if (!array && !record) return undefined; const entries = array ? values!.map((entry, index) => Object.freeze({ key: String(index), value: entry })) - : Object.keys(record!).map((key) => Object.freeze({ key, value: record![key] })); + : (reflectApplyIntrinsic(objectKeysIntrinsic, Object, [record]) as string[]).map((key) => + Object.freeze({ key, value: record![key] }) + ); return Object.freeze({ array, entries: Object.freeze(entries) }); } diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 8df251f24..1287a3e72 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -659,12 +659,13 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const valid = largeAdmProjection([16]); - const invalid = largeAdmProjection([16]); - invalid.bids[0]!.provider = '-invalid'; + const invalid = { ...largeAdmProjection([16]), unknown: true }; const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; let parsed: BrowserAuctionProjectionV1 | undefined; let rejected: BrowserAuctionProjectionV1 | undefined; Object.defineProperty(Array.prototype, Symbol.iterator, { @@ -688,6 +689,20 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { throw new Error('poisoned regular expression'); }, }); + Object.defineProperty(Array.prototype, 'every', { + configurable: true, + value: () => { + calls.every += 1; + throw new Error('poisoned array every'); + }, + }); + Object.defineProperty(Array.prototype, 'includes', { + configurable: true, + value: () => { + calls.includes += 1; + throw new Error('poisoned array includes'); + }, + }); try { parsed = parseBrowserAuctionProjectionV1(valid); rejected = parseBrowserAuctionProjectionV1(invalid); @@ -698,11 +713,14 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { if (encodeDescriptor) Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); + if (includesDescriptor) + Object.defineProperty(Array.prototype, 'includes', includesDescriptor); } expect(parsed).toBeDefined(); expect(rejected).toBeUndefined(); - expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); }); it('requires cache sources to match one frozen cache policy exactly', () => { diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index b2c63c311..2835ea05c 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -253,11 +253,13 @@ describe('registry', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const validCandidate = unit('poison-safe'); - const invalidCandidate = { ...unit('invalid-bidder'), bids: [{ bidder: '' }] }; + const invalidCandidate = { ...unit('unknown-key'), unknown: true }; const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; let prepared: ReturnType | undefined; let invalidError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { @@ -281,6 +283,20 @@ describe('registry', () => { throw new Error('poisoned regular expression'); }, }); + Object.defineProperty(Array.prototype, 'every', { + configurable: true, + value: () => { + calls.every += 1; + throw new Error('poisoned array every'); + }, + }); + Object.defineProperty(Array.prototype, 'includes', { + configurable: true, + value: () => { + calls.includes += 1; + throw new Error('poisoned array includes'); + }, + }); try { prepared = prepareProgrammaticAdUnits(validCandidate, new Set()); try { @@ -295,12 +311,15 @@ describe('registry', () => { if (encodeDescriptor) Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); + if (includesDescriptor) + Object.defineProperty(Array.prototype, 'includes', includesDescriptor); } expect(prepared?.[0]?.code).toBe('poison-safe'); expect(invalidError).toBeInstanceOf(AdUnitRegistrationError); - expect(invalidError).toMatchObject({ code: 'invalid_bidder', unitIndex: 0 }); - expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + expect(invalidError).toMatchObject({ code: 'invalid_unit', unitIndex: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); }); it('serializes detached auction data without invoking inherited toJSON hooks', () => { diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index dc3d85c8f..5b69140d3 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -96,11 +96,13 @@ describe('requestAds input contract', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); + const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); + const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; let validated: ReturnType | undefined; - let duplicateError: unknown; + let unknownKeyError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: () => { @@ -122,12 +124,26 @@ describe('requestAds input contract', () => { throw new Error('poisoned regular expression'); }, }); + Object.defineProperty(Array.prototype, 'every', { + configurable: true, + value: () => { + calls.every += 1; + throw new Error('poisoned array every'); + }, + }); + Object.defineProperty(Array.prototype, 'includes', { + configurable: true, + value: () => { + calls.includes += 1; + throw new Error('poisoned array includes'); + }, + }); try { validated = validateRequestAdsOptions({ slots: ['slot-one'], timeoutMs: 100 }); try { - validateRequestAdsOptions({ slots: ['slot-one', 'slot-one'] }); + validateRequestAdsOptions({ unknown: true }); } catch (error) { - duplicateError = error; + unknownKeyError = error; } } finally { if (iteratorDescriptor) { @@ -136,12 +152,15 @@ describe('requestAds input contract', () => { if (encodeDescriptor) Object.defineProperty(TextEncoder.prototype, 'encode', encodeDescriptor); if (testDescriptor) Object.defineProperty(RegExp.prototype, 'test', testDescriptor); + if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); + if (includesDescriptor) + Object.defineProperty(Array.prototype, 'includes', includesDescriptor); } expect(validated).toMatchObject({ slots: ['slot-one'], timeoutMs: 100 }); - expect(duplicateError).toBeInstanceOf(RequestAdsInputError); - expect(duplicateError).toMatchObject({ code: 'duplicate_slot' }); - expect(calls).toEqual({ encode: 0, iterator: 0, test: 0 }); + expect(unknownKeyError).toBeInstanceOf(RequestAdsInputError); + expect(unknownKeyError).toMatchObject({ code: 'invalid_options' }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); }); }); From 87144b65d588a232a8ee8f7892b7217c773fe42f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:18:13 -0700 Subject: [PATCH 331/494] Remove ambient validation predicates --- .../src/core/contracts/auction_projection.ts | 20 ++++++--------- .../lib/src/core/registry.ts | 21 +++++++++------- .../lib/test/core/auction.test.ts | 18 +++++++++++-- .../lib/test/core/registry.test.ts | 25 +++++++++++++++++-- .../lib/test/core/request.test.ts | 13 ++++++++-- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts index b2a8bfbd1..1c29574d2 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -603,19 +603,15 @@ export function parseBrowserAuctionProjectionV1( bids.push(bid); } - const winners = auction.results.filter( - (result): result is Extract => - result.outcome === 'winner' - ); - if ( - winners.length !== bids.length || - winners.some( - (winner, index) => - bids[index]?.candidateId !== winner.candidateId || bids[index]?.slot !== winner.slot - ) - ) { - return undefined; + let winnerIndex = 0; + for (let index = 0; index < auction.results.length; index += 1) { + const result = auction.results[index]; + if (!result || result.outcome !== 'winner') continue; + const bid = bids[winnerIndex]; + if (bid?.candidateId !== result.candidateId || bid.slot !== result.slot) return undefined; + winnerIndex += 1; } + if (winnerIndex !== bids.length) return undefined; const projection: BrowserAuctionProjectionV1 = { version: 1, auction, bids }; if (jsonUtf8ByteLength(projection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 3a512eb48..ec8c1e465 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -166,6 +166,12 @@ function jsonPrimitive(value: unknown): null | boolean | number | string | undef return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function validPositiveInteger(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0 + ); +} + function snapshotJsonContainer(value: object): JsonContainerSnapshot | undefined { const array = Array.isArray(value); const values = array ? ownDataArray(value, MAX_JSON_STRUCTURE_ENTRIES) : undefined; @@ -520,23 +526,20 @@ export function prepareProgrammaticAdUnits( for (let sizeIndex = 0; sizeIndex < rawSizes.length; sizeIndex += 1) { const rawSize = rawSizes[sizeIndex]; const dimensions = ownDataArray(rawSize, 2); + const width = dimensions?.[0]; + const height = dimensions?.[1]; if ( !dimensions || dimensions.length !== 2 || - dimensions.some( - (dimension) => - typeof dimension !== 'number' || - !Number.isFinite(dimension) || - !Number.isInteger(dimension) || - dimension <= 0 - ) + !validPositiveInteger(width) || + !validPositiveInteger(height) ) { throw new AdUnitRegistrationError('invalid_dimensions', index); } - if (dimensions.some((dimension) => (dimension as number) > 4_096)) { + if (width > 4_096 || height > 4_096) { throw new AdUnitRegistrationError('dimensions_out_of_range', index); } - sizes.push(Object.freeze([dimensions[0] as number, dimensions[1] as number])); + sizes.push(Object.freeze([width, height])); } let bids: readonly PendingProgrammaticBid[] | undefined; diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 1287a3e72..173e35a01 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -660,14 +660,18 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const valid = largeAdmProjection([16]); const invalid = { ...largeAdmProjection([16]), unknown: true }; + const mismatchedWinner = largeAdmProjection([16]); + mismatchedWinner.auction.results[0]!.slot = 'mismatched-slot'; const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); + const someDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'some'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }; let parsed: BrowserAuctionProjectionV1 | undefined; let rejected: BrowserAuctionProjectionV1 | undefined; + let rejectedMismatch: BrowserAuctionProjectionV1 | undefined; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: () => { @@ -703,9 +707,17 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { throw new Error('poisoned array includes'); }, }); + Object.defineProperty(Array.prototype, 'some', { + configurable: true, + value: () => { + calls.some += 1; + throw new Error('poisoned array some'); + }, + }); try { parsed = parseBrowserAuctionProjectionV1(valid); rejected = parseBrowserAuctionProjectionV1(invalid); + rejectedMismatch = parseBrowserAuctionProjectionV1(mismatchedWinner); } finally { if (iteratorDescriptor) { Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); @@ -716,11 +728,13 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); if (includesDescriptor) Object.defineProperty(Array.prototype, 'includes', includesDescriptor); + if (someDescriptor) Object.defineProperty(Array.prototype, 'some', someDescriptor); } expect(parsed).toBeDefined(); expect(rejected).toBeUndefined(); - expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); + expect(rejectedMismatch).toBeUndefined(); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }); }); it('requires cache sources to match one frozen cache policy exactly', () => { diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts index 2835ea05c..7fb1ed865 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -254,14 +254,20 @@ describe('registry', () => { it('uses captured validation intrinsics after platform prototypes are poisoned', () => { const validCandidate = unit('poison-safe'); const invalidCandidate = { ...unit('unknown-key'), unknown: true }; + const invalidDimensions = { + ...unit('invalid-dimensions'), + mediaTypes: { banner: { sizes: [['bad', 250]] } }, + }; const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); + const someDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'some'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }; let prepared: ReturnType | undefined; let invalidError: unknown; + let invalidDimensionsError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: () => { @@ -297,6 +303,13 @@ describe('registry', () => { throw new Error('poisoned array includes'); }, }); + Object.defineProperty(Array.prototype, 'some', { + configurable: true, + value: () => { + calls.some += 1; + throw new Error('poisoned array some'); + }, + }); try { prepared = prepareProgrammaticAdUnits(validCandidate, new Set()); try { @@ -304,6 +317,11 @@ describe('registry', () => { } catch (error) { invalidError = error; } + try { + prepareProgrammaticAdUnits(invalidDimensions, new Set()); + } catch (error) { + invalidDimensionsError = error; + } } finally { if (iteratorDescriptor) { Object.defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); @@ -314,12 +332,15 @@ describe('registry', () => { if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); if (includesDescriptor) Object.defineProperty(Array.prototype, 'includes', includesDescriptor); + if (someDescriptor) Object.defineProperty(Array.prototype, 'some', someDescriptor); } expect(prepared?.[0]?.code).toBe('poison-safe'); expect(invalidError).toBeInstanceOf(AdUnitRegistrationError); expect(invalidError).toMatchObject({ code: 'invalid_unit', unitIndex: 0 }); - expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); + expect(invalidDimensionsError).toBeInstanceOf(AdUnitRegistrationError); + expect(invalidDimensionsError).toMatchObject({ code: 'invalid_dimensions', unitIndex: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }); }); it('serializes detached auction data without invoking inherited toJSON hooks', () => { diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts index 5b69140d3..8a558d8db 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -98,9 +98,10 @@ describe('requestAds input contract', () => { const iteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator); const everyDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'every'); const includesDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'includes'); + const someDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'some'); const encodeDescriptor = Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode'); const testDescriptor = Object.getOwnPropertyDescriptor(RegExp.prototype, 'test'); - const calls = { encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }; + const calls = { encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }; let validated: ReturnType | undefined; let unknownKeyError: unknown; Object.defineProperty(Array.prototype, Symbol.iterator, { @@ -138,6 +139,13 @@ describe('requestAds input contract', () => { throw new Error('poisoned array includes'); }, }); + Object.defineProperty(Array.prototype, 'some', { + configurable: true, + value: () => { + calls.some += 1; + throw new Error('poisoned array some'); + }, + }); try { validated = validateRequestAdsOptions({ slots: ['slot-one'], timeoutMs: 100 }); try { @@ -155,12 +163,13 @@ describe('requestAds input contract', () => { if (everyDescriptor) Object.defineProperty(Array.prototype, 'every', everyDescriptor); if (includesDescriptor) Object.defineProperty(Array.prototype, 'includes', includesDescriptor); + if (someDescriptor) Object.defineProperty(Array.prototype, 'some', someDescriptor); } expect(validated).toMatchObject({ slots: ['slot-one'], timeoutMs: 100 }); expect(unknownKeyError).toBeInstanceOf(RequestAdsInputError); expect(unknownKeyError).toMatchObject({ code: 'invalid_options' }); - expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, test: 0 }); + expect(calls).toEqual({ encode: 0, every: 0, includes: 0, iterator: 0, some: 0, test: 0 }); }); }); From 3e02edcaadc2ab5ec1c7eec9e0b0f39d2117ec6e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:25:48 -0700 Subject: [PATCH 332/494] Stamp the external Prebid artifact --- .../lib/build-prebid-external.mjs | 149 +++++++++++----- .../lib/test/build-prebid-external.test.mjs | 38 +++- .../test/prebid-artifact-integration.test.mjs | 164 +++++++++++++++++- 3 files changed, 292 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 0718bdeee..128335ac0 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -34,6 +34,9 @@ const PREBID_LIVE_INTENT_STANDARD = path.join( ); const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); const LIVE_INTENT_SHIM = path.join(prebidDir, 'prebid_modules', 'liveIntentIdSystem.ts'); +export const ARTIFACT_RELEASE_SENTINEL = '0'.repeat(64); +const ARTIFACT_PROPERTY = '__trustedServerArtifactV1'; +const EXPECTED_PREBID_VERSION = '10.26.0'; export function parseArgs(argv) { const options = new Map(); @@ -138,8 +141,13 @@ export function renderIncludedUserIdModulesExport(moduleNames) { * list, while the module-name list is retained separately for audit output. */ export function readAdapterBidderCodes(adapterNames) { + return readAdapterMetadata(adapterNames).bidderCodes; +} + +export function readAdapterMetadata(adapterNames) { const metadataDir = path.join(PREBID_PACKAGE_DIR, 'metadata', 'modules'); const bidderCodes = new Set(); + const bidderAliases = []; for (const name of adapterNames) { const metadataPath = path.join(metadataDir, `${name}BidAdapter.json`); @@ -160,10 +168,19 @@ export function readAdapterBidderCodes(adapterNames) { } for (const component of bidderComponents) { bidderCodes.add(component.componentName); + if (typeof component.aliasOf === 'string' && component.aliasOf.length > 0) { + bidderAliases.push({ code: component.componentName, moduleStem: name }); + } } } - return [...bidderCodes].sort(); + return { + bidderCodes: [...bidderCodes].sort(), + bidderAliases: bidderAliases.sort( + (left, right) => + left.code.localeCompare(right.code) || left.moduleStem.localeCompare(right.moduleStem) + ), + }; } function generateAdapterImports(adapterNames, adaptersFile) { @@ -213,7 +230,13 @@ function generateUserIdImports(requestedModules, userIdsFile) { imports, [renderIncludedUserIdModulesExport(moduleNames)] ); - return moduleNames; + return selectedEntries + .map((entry) => ({ + moduleName: entry.moduleName, + configNames: [...new Set(entry.configNames)].sort(), + eidSources: [...new Set(entry.eidSources.map((source) => source.toLowerCase()))].sort(), + })) + .sort((left, right) => left.moduleName.localeCompare(right.moduleName)); } function createTemporaryModulePaths() { @@ -228,50 +251,20 @@ function createTemporaryModulePaths() { const SHIM_WATCHDOG_DELAY_MS = 5000; -function generateExternalEntry(entryFile, adapters, bidderCodes) { +function generateExternalEntry(entryFile) { const content = [ '// Auto-generated by build-prebid-external.mjs.', '//', '// Pure Prebid.js external bundle: core, consent modules, user ID modules,', - '// and client-side bid adapters. The Trusted Server prebid shim', - '// (tsjs-prebid, served by the server) installs the trustedServer adapter', - '// onto the `window.pbjs` global this bundle populates and drives queue', - '// processing — this bundle intentionally does NOT call processQueue()', - '// itself, except through the watchdog below.', + '// and client-side bid adapters. Trusted Server auction, admission, render,', + '// targeting, and refresh behavior intentionally live outside this artifact.', "import 'prebid.js';", "import 'prebid.js/modules/consentManagementTcf.js';", "import 'prebid.js/modules/consentManagementGpp.js';", "import 'prebid.js/modules/consentManagementUsp.js';", "import 'prebid.js/modules/userId.js';", "import './_adapters.generated';", - "import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated';", - '', - '// Manifest consumed by the tsjs prebid shim to validate that every', - '// configured client_side_bidder has its adapter compiled in. adapters', - '// lists the module file stems for audit output; bidderCodes lists the', - '// registered runtime bidder codes, including aliases.', - 'const bundleWindow = window as unknown as {', - ' __tsjs_prebid_bundle?: unknown;', - ' __tsjsPrebidShimInstalled?: boolean;', - ' pbjs?: { processQueue?: () => void };', - '};', - 'bundleWindow.__tsjs_prebid_bundle = Object.freeze({', - ` adapters: ${JSON.stringify(adapters)},`, - ` bidderCodes: ${JSON.stringify(bidderCodes)},`, - ' userIdModules: INCLUDED_PREBID_USER_ID_MODULES,', - '});', - '', - '// Watchdog: the shim owns processQueue(), but it is a separate artifact', - '// that can fail to load independently (adblock filters, CSP, a', - '// /static/tsjs= error). If it has not installed within the grace period,', - '// drain the queue anyway so publisher pbjs.que callbacks still run', - '// against plain Prebid.js. processQueue() is safe to call again when the', - '// shim arrives late.', - 'setTimeout(() => {', - ' if (!bundleWindow.__tsjsPrebidShimInstalled) {', - ' bundleWindow.pbjs?.processQueue?.();', - ' }', - `}, ${SHIM_WATCHDOG_DELAY_MS});`, + "import './_user_ids.generated';", '', ].join('\n'); @@ -286,7 +279,39 @@ export function deriveBundleMetadata(bundleBytes) { return { filename, sha256, sri }; } -async function buildExternalBundle(outDir, generatedModules) { +function sha256Hex(bytes) { + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +function renderExternalWrapper(bundleCode, stamp) { + const stampJson = JSON.stringify(stamp); + return [ + '(function(){', + `var __tsWatchdog=setTimeout(function(){if(__tsWatchdogFired)return;__tsWatchdogFired=true;try{var p=window.pbjs;var f=p&&p.processQueue;if(typeof f==="function")Reflect.apply(f,p,[]);}catch(_){}},${SHIM_WATCHDOG_DELAY_MS});`, + 'var __tsWatchdogFired=false;', + 'void __tsWatchdog;', + 'var __tsMissing={};', + 'var __tsWarned=false;', + 'function __tsWarn(){if(__tsWarned)return;__tsWarned=true;try{console.warn("[tsjs-prebid] external Prebid artifact stamp conflict");}catch(_){}}', + 'function __tsData(value,key){try{var descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&Object.prototype.hasOwnProperty.call(descriptor,"value")&&descriptor.enumerable===true&&descriptor.writable===false&&descriptor.configurable===false?descriptor.value:__tsMissing;}catch(_){return __tsMissing;}}', + 'function __tsRecord(value,keys){if(!value||typeof value!=="object"||Object.getPrototypeOf(value)!==Object.prototype||!Object.isFrozen(value))return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==keys.length)return false;for(var i=0;imax)return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==value.length+1)return false;for(var i=0;i256||(previous!==undefined&&previous>=current))return false;previous=current;}return true;}', + 'function __tsContains(values,expected){for(var i=0;i=identity)||!__tsContains(bidders,code)||!__tsContains(modules,stem))return false;previous=identity;}previous="";for(var j=0;j=name)||!__tsContains(modules,name)||!__tsSortedStrings(configs,64)||!__tsSortedStrings(sources,64))return false;for(var k=0;k moduleName)]), + ].sort(); + const stamp = { + abi: 1, + artifactReleaseId: ARTIFACT_RELEASE_SENTINEL, + prebidVersion: EXPECTED_PREBID_VERSION, + moduleStems, + bidderCodes: adapterMetadata.bidderCodes, + bidderAliases: adapterMetadata.bidderAliases, userIdModules, + }; + const bundle = await buildExternalBundle(args.outDir, generatedModules, stamp); + const manifest = { + ...stamp, + artifactReleaseId: bundle.artifactReleaseId, sha256: bundle.sha256, sri: bundle.sri, filename: bundle.filename, diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index f22717f79..520723351 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -8,6 +8,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + ARTIFACT_RELEASE_SENTINEL, deriveBundleMetadata, main, parseArgs, @@ -70,10 +71,41 @@ describe('build-prebid-external metadata', () => { ); const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); + expect(manifest).toMatchObject({ + abi: 1, + prebidVersion: '10.26.0', + moduleStems: ['lockrAIMIdSystem', 'pairIdSystem', 'rubicon'], + bidderCodes: ['rubicon'], + bidderAliases: [], + userIdModules: [ + { + moduleName: 'lockrAIMIdSystem', + configNames: ['lockrAIMId'], + eidSources: [], + }, + { + moduleName: 'pairIdSystem', + configNames: ['pairId'], + eidSources: ['google.com'], + }, + ], + }); + expect(manifest.artifactReleaseId).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.filename).toMatch(/^trusted-prebid-[0-9a-f]{64}\.js$/); + expect(manifest.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(manifest.sri).toMatch(/^sha384-/); + expect(bundle).toContain('__trustedServerArtifactV1'); + expect(bundle).toContain(manifest.artifactReleaseId); + expect(bundle).not.toContain(ARTIFACT_RELEASE_SENTINEL); + expect(bundle).not.toContain('__tsjs_prebid_bundle'); + expect(bundle).not.toContain('__tsjsPrebidShimInstalled'); expect(manifest.bidderCodes).toEqual(['rubicon']); - expect(bundle).toContain('"pairIdSystem"'); - expect(bundle).toContain('"lockrAIMIdSystem"'); + expect(bundle.split(manifest.artifactReleaseId)).toHaveLength(2); + const normalized = bundle.replace(manifest.artifactReleaseId, ARTIFACT_RELEASE_SENTINEL); + expect(crypto.createHash('sha256').update(normalized).digest('hex')).toBe( + manifest.artifactReleaseId + ); + expect(crypto.createHash('sha256').update(bundle).digest('hex')).toBe(manifest.sha256); } finally { fs.rmSync(outputDirectory, { recursive: true, force: true }); } diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 6582d9089..aaf2e5091 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -26,6 +26,7 @@ let outputDirectory; let bundleCode; let shimCode; let prebidVersion; +let artifactManifest; beforeAll(async () => { outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-server-prebid-artifacts-')); @@ -38,9 +39,11 @@ beforeAll(async () => { '--out', outputDirectory, ]); - const manifest = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8')); - bundleCode = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); - prebidVersion = manifest.prebidVersion; + artifactManifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + bundleCode = fs.readFileSync(path.join(outputDirectory, artifactManifest.filename), 'utf8'); + prebidVersion = artifactManifest.prebidVersion; const { build } = await import('vite'); await build({ @@ -88,6 +91,117 @@ describe('tsjs-prebid shim artifact', () => { }); describe('external bundle + served shim evaluated together', () => { + it('reuses an exact artifact without replaying factories and keeps one watchdog per wrapper', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const watchdogs = []; + const originalSetTimeout = pageWindow.setTimeout.bind(pageWindow); + pageWindow.setTimeout = (callback, delay, ...arguments_) => { + if (delay === 5_000 && String(callback).includes('__tsWatchdogFired')) { + watchdogs.push(callback); + return 1; + } + return originalSetTimeout(callback, delay, ...arguments_); + }; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + + pageWindow.eval(bundleCode); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(bundleCode); + + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); + } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); + + it('refuses a different valid artifact without disturbing the working binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const conflictingStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.eval( + `window.__conflictingStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify(conflictingStamp)});` + ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__conflictingStamp, + enumerable: false, + writable: false, + configurable: false, + }); + const binding = pageWindow.pbjs; + const warn = vi.fn(); + pageWindow.console.warn = warn; + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs).toBe(binding); + expect(pageWindow.pbjs.requestBids).toBeUndefined(); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__conflictingStamp); + expect(warn).toHaveBeenCalledTimes(1); + dom.window.close(); + }); + + it('keeps publisher Prebid usable when a hostile stamp cannot be replaced', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + const hostileStamp = Object.freeze({ abi: 99 }); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: hostileStamp, + enumerable: true, + writable: false, + configurable: false, + }); + const warn = vi.fn(); + pageWindow.console.warn = warn; + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(hostileStamp); + expect(warn).toHaveBeenCalledTimes(1); + dom.window.close(); + }); + it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', @@ -134,13 +248,45 @@ describe('external bundle + served shim evaluated together', () => { expect(typeof pageWindow.pbjs.requestBids).toBe('function'); expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); - expect(pageWindow.__tsjs_prebid_bundle.adapters).toEqual(['adf']); - expect([...pageWindow.__tsjs_prebid_bundle.bidderCodes]).toEqual([ - 'adf', - 'adform', - 'adformOpenRTB', + expect(pageWindow.__tsjs_prebid_bundle).toBeUndefined(); + expect(pageWindow.__tsjsPrebidShimInstalled).toBeUndefined(); + const artifactDescriptor = Object.getOwnPropertyDescriptor( + pageWindow.pbjs, + '__trustedServerArtifactV1' + ); + expect(artifactDescriptor).toMatchObject({ + enumerable: false, + writable: false, + configurable: false, + }); + expect(artifactDescriptor.value).toEqual( + expect.objectContaining({ + abi: 1, + artifactReleaseId: artifactManifest.artifactReleaseId, + prebidVersion: '10.26.0', + }) + ); + expect([...artifactDescriptor.value.bidderCodes]).toEqual(['adf', 'adform', 'adformOpenRTB']); + expect([...artifactDescriptor.value.bidderAliases]).toEqual([ + { code: 'adform', moduleStem: 'adf' }, + { code: 'adformOpenRTB', moduleStem: 'adf' }, + ]); + expect([...artifactDescriptor.value.userIdModules]).toEqual([ + { + moduleName: 'sharedIdSystem', + configNames: ['pubCommonId', 'sharedId'], + eidSources: ['pubcid.org'], + }, ]); - expect([...pageWindow.__tsjs_prebid_bundle.userIdModules]).toEqual(['sharedIdSystem']); + expect(Object.isFrozen(artifactDescriptor.value)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.moduleStems)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.bidderCodes)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.bidderAliases)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.bidderAliases[0])).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0])).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].configNames)).toBe(true); + expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].eidSources)).toBe(true); // Count trustedServer registrations across repeated shim evaluations. const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); From b9abc2e614da28e5381441971f8475ae7f8f3ad1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:29:25 -0700 Subject: [PATCH 333/494] Harden external Prebid artifact binding --- .../lib/build-prebid-external.mjs | 15 ++++++++---- .../lib/src/adapters/prebid.ts | 23 +++++++++++-------- .../lib/test/adapters/prebid.test.ts | 10 ++++---- .../lib/test/build-prebid-external.test.mjs | 10 ++++++++ .../test/prebid-artifact-integration.test.mjs | 4 ++++ 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 128335ac0..1aaebb606 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -69,10 +69,14 @@ export function parseArgs(argv) { } function parseList(raw) { - return raw + const values = raw .split(',') .map((value) => value.trim()) .filter(Boolean); + if (new Set(values).size !== values.length) { + throw new Error('[build-prebid-external] Module lists must not contain duplicates'); + } + return values.sort(); } function requireExistingFile(filePath, description) { @@ -178,7 +182,8 @@ export function readAdapterMetadata(adapterNames) { bidderCodes: [...bidderCodes].sort(), bidderAliases: bidderAliases.sort( (left, right) => - left.code.localeCompare(right.code) || left.moduleStem.localeCompare(right.moduleStem) + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.moduleStem < right.moduleStem ? -1 : left.moduleStem > right.moduleStem ? 1 : 0) ), }; } @@ -236,7 +241,9 @@ function generateUserIdImports(requestedModules, userIdsFile) { configNames: [...new Set(entry.configNames)].sort(), eidSources: [...new Set(entry.eidSources.map((source) => source.toLowerCase()))].sort(), })) - .sort((left, right) => left.moduleName.localeCompare(right.moduleName)); + .sort((left, right) => + left.moduleName < right.moduleName ? -1 : left.moduleName > right.moduleName ? 1 : 0 + ); } function createTemporaryModulePaths() { @@ -305,7 +312,7 @@ function renderExternalWrapper(bundleCode, stamp) { `var __tsExistingWindow=window;var __tsExisting=__tsExistingWindow.pbjs;var __tsExistingDescriptor;try{__tsExistingDescriptor=__tsExisting&&Object.getOwnPropertyDescriptor(__tsExisting,"${ARTIFACT_PROPERTY}");}catch(_){__tsExistingDescriptor=undefined;}`, 'if(__tsExistingDescriptor&&Object.prototype.hasOwnProperty.call(__tsExistingDescriptor,"value")&&__tsExistingDescriptor.enumerable===false&&__tsExistingDescriptor.writable===false&&__tsExistingDescriptor.configurable===false&&__tsValidStamp(__tsExistingDescriptor.value)){if(__tsEqual(__tsExistingDescriptor.value,__tsStamp))return;__tsWarn();return;}', bundleCode, - `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;}if(!__tsAfter){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, + `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;var __tsInherited=false;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");__tsInherited=!__tsAfter&&Reflect.has(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;__tsInherited=true;}if(!__tsAfter&&!__tsInherited){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(__tsInherited||!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, '})();', '', ].join('\n'); diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 07e87ac50..a0d204e27 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -67,9 +67,9 @@ export interface PrebidArtifactRequirements { /** The small Prebid surface exposed to an accepted operation. */ export interface PrebidFacade { addAdUnits(adUnits: readonly unknown[]): unknown; - addBidResponse(adUnitCode: string, bid: object): unknown; highestBids(adUnitCode?: string): readonly object[]; processQueue(): unknown; + registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; subscribe(eventType: string, listener: (event: unknown) => void): () => void; @@ -176,13 +176,11 @@ function frozenRecordValues( value: unknown, keys: readonly string[] ): Readonly> | undefined { - if ( - typeof value !== 'object' || - value === null || - Object.getPrototypeOf(value) !== Object.prototype - ) { + if (typeof value !== 'object' || value === null) { return undefined; } + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && Object.getPrototypeOf(prototype) !== null) return undefined; if (!Object.isFrozen(value)) return undefined; let ownKeys: PropertyKey[]; let descriptors: Record; @@ -224,7 +222,7 @@ function validString(value: unknown, maximumBytes: number, lowercase = false): v } function frozenArrayValues(value: unknown, maximumLength: number): readonly unknown[] | undefined { - if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + if (!Array.isArray(value)) return undefined; if (!Object.isFrozen(value)) return undefined; const descriptors = Object.getOwnPropertyDescriptors(value); const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); @@ -380,11 +378,11 @@ function validateStamp( const REQUIRED_API_METHODS = [ 'addAdUnits', - 'addBidResponse', 'getHighestCpmBids', 'offEvent', 'onEvent', 'processQueue', + 'registerBidAdapter', 'renderAd', 'requestBids', ] as const; @@ -559,8 +557,6 @@ export function createBrowserPrebidAdapter( Object.freeze({ addAdUnits: (adUnits: readonly unknown[]): unknown => callBound(binding, 'addAdUnits', [[...adUnits]], isOperationCurrent), - addBidResponse: (adUnitCode: string, bid: object): unknown => - callBound(binding, 'addBidResponse', [adUnitCode, bid], isOperationCurrent), highestBids: (adUnitCode?: string): readonly object[] => { const value = callBound( binding, @@ -575,6 +571,13 @@ export function createBrowserPrebidAdapter( return Object.freeze([...value]); }, processQueue: (): unknown => callBound(binding, 'processQueue', [], isOperationCurrent), + registerBidAdapter: (adapter: unknown, bidderCode: string, spec?: object): unknown => + callBound( + binding, + 'registerBidAdapter', + spec === undefined ? [adapter, bidderCode] : [adapter, bidderCode, spec], + isOperationCurrent + ), renderAd: (targetDocument: object, adId: string): unknown => callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index c6ce9f924..0b6108924 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -41,7 +41,6 @@ function createReadyPrebid( const listeners = new Map void>>(); const pbjs = { addAdUnits: vi.fn(), - addBidResponse: vi.fn(), getHighestCpmBids: vi.fn<() => object[]>(() => []), offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { listeners.get(type)?.delete(listener); @@ -52,6 +51,7 @@ function createReadyPrebid( listeners.set(type, registered); }), processQueue: vi.fn(), + registerBidAdapter: vi.fn(), que: { push: vi.fn((command: Command): number => { if (options.deferCommands) commands.push(command); @@ -83,7 +83,7 @@ describe('browser Prebid adapter readiness', () => { expect('que' in prebid).toBe(false); expect('__trustedServerArtifactV1' in prebid).toBe(false); prebid.addAdUnits([{ code: 'slot-a' }]); - prebid.addBidResponse('slot-a', { adId: 'bid-a' }); + prebid.registerBidAdapter(undefined, 'trustedServer', { code: 'trustedServer' }); prebid.requestBids({ adUnitCodes: ['slot-a'] }); prebid.renderAd({}, 'bid-a'); return prebid.highestBids('slot-a'); @@ -92,7 +92,9 @@ describe('browser Prebid adapter readiness', () => { expect(operation.status).toBe('present'); await expect(operation.result).resolves.toEqual([]); expect(ready.pbjs.addAdUnits).toHaveBeenCalledTimes(1); - expect(ready.pbjs.addBidResponse).toHaveBeenCalledWith('slot-a', { adId: 'bid-a' }); + expect(ready.pbjs.registerBidAdapter).toHaveBeenCalledWith(undefined, 'trustedServer', { + code: 'trustedServer', + }); expect(ready.pbjs.requestBids).toHaveBeenCalledTimes(1); expect(ready.pbjs.renderAd).toHaveBeenCalledWith({}, 'bid-a'); }); @@ -601,11 +603,11 @@ describe('browser Prebid adapter readiness', () => { it('requires every real API method and contains hostile target and member getters', async () => { for (const method of [ 'addAdUnits', - 'addBidResponse', 'getHighestCpmBids', 'offEvent', 'onEvent', 'processQueue', + 'registerBidAdapter', 'renderAd', 'requestBids', ] as const) { diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 520723351..b6afbb0b2 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -13,6 +13,7 @@ import { main, parseArgs, readAdapterBidderCodes, + readAdapterMetadata, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; @@ -38,6 +39,10 @@ describe('build-prebid-external metadata', () => { it('derives registered bidder codes including aliases from prebid metadata', () => { // adfBidAdapter.js registers adf plus the adform/adformOpenRTB aliases. expect(readAdapterBidderCodes(['adf'])).toEqual(['adf', 'adform', 'adformOpenRTB']); + expect(readAdapterMetadata(['adf']).bidderAliases).toEqual([ + { code: 'adform', moduleStem: 'adf' }, + { code: 'adformOpenRTB', moduleStem: 'adf' }, + ]); }); it('maps a module file stem to its registered bidder code', () => { @@ -116,4 +121,9 @@ describe('build-prebid-external metadata', () => { expect(parsed.outDir).toBe(path.resolve(process.cwd(), 'dist/prebid')); }); + + it('canonicalizes module order and rejects duplicate module names', () => { + expect(parseArgs(['--adapters', 'rubicon,adf']).adapters).toEqual(['adf', 'rubicon']); + expect(() => parseArgs(['--adapters', 'rubicon,rubicon'])).toThrow(/duplicates/); + }); }); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index aaf2e5091..56c52349d 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -18,6 +18,7 @@ import { JSDOM } from 'jsdom'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { main } from '../build-prebid-external.mjs'; +import { createBrowserPrebidAdapter } from '../src/adapters/prebid'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const libDir = path.resolve(__dirname, '..'); @@ -287,6 +288,9 @@ describe('external bundle + served shim evaluated together', () => { expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0])).toBe(true); expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].configNames)).toBe(true); expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].eidSources)).toBe(true); + const adapter = createBrowserPrebidAdapter(pageWindow); + expect(adapter.bindingStatus()).toBe('present'); + adapter.dispose(); // Count trustedServer registrations across repeated shim evaluations. const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); From fa5eccc9d3aa8463e4b5ff9c9dbce77261fd8ab9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:30:16 -0700 Subject: [PATCH 334/494] Retire artifacts with GPT slot ownership --- .../lib/src/composition/browser.ts | 10 ++- .../lib/src/kernel/sessions.ts | 9 ++ .../lib/src/services/slots.ts | 41 ++++++++- .../lib/test/composition/browser.test.ts | 40 +++++++++ .../lib/test/kernel/sessions.test.ts | 19 ++++ .../lib/test/services/slots.test.ts | 86 +++++++++++++++---- 6 files changed, 184 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 25d7c239f..a4c16a404 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -474,7 +474,14 @@ export function createTestBrowserRuntimeComposition( typeof document === 'undefined' || typeof MutationObserver === 'undefined' ? undefined : createBrowserSlotReconciliationBoundary(document, MutationObserver); + const artifacts = createCommittedArtifactStore(); const slotService = createSlotService({ + disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + const artifact = artifacts.current(registeredSlotId); + if (artifact?.navigationGeneration === navigationGeneration) { + artifacts.release(artifact); + } + }, googletag: composition.adapters.googletag, ...(reconciliation ? { reconciliation } : {}), }); @@ -482,7 +489,6 @@ export function createTestBrowserRuntimeComposition( const reservationService = createReservationService({ prepareRenderSource: (candidate) => parseBidRenderSourceV1(candidate, cachePolicy), }); - const artifacts = createCommittedArtifactStore(); const rendererNonces = createRendererNonceRegistry(); const publisherOrigin = window.location.origin; const fetchCache = globalThis.fetch; @@ -654,6 +660,8 @@ export function createTestBrowserRuntimeComposition( createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, interfaces: Object.freeze({ adapters: composition.adapters, gpt: gptRuntime, ...services }), + onNavigationDispose: (navigationGeneration) => + artifacts.disposeNavigation(navigationGeneration), }); context.onDispose(() => { batchCoordinator.dispose(); diff --git a/crates/trusted-server-js/lib/src/kernel/sessions.ts b/crates/trusted-server-js/lib/src/kernel/sessions.ts index a301cf9d1..4ae8d6fc2 100644 --- a/crates/trusted-server-js/lib/src/kernel/sessions.ts +++ b/crates/trusted-server-js/lib/src/kernel/sessions.ts @@ -29,6 +29,7 @@ export type RuntimeInterfaces = Readonly>; export interface RuntimeSessionOptions { readonly createIdentityIssuer: NavigationIdentityIssuerFactory; readonly interfaces?: RuntimeInterfaces; + readonly onNavigationDispose?: (navigationGeneration: object) => void; readonly onDisposalError?: DisposalErrorHandler; } @@ -597,6 +598,7 @@ class RuntimeSessionOwner implements RuntimeSession { public readonly interfaces: RuntimeInterfaces; private readonly scope: OwnerScope; private readonly createIdentityIssuer: NavigationIdentityIssuerFactory; + private readonly onNavigationDispose: ((navigationGeneration: object) => void) | undefined; private readonly onDisposalError: DisposalErrorHandler | undefined; private navigation: NavigationSessionOwner | undefined; private started = false; @@ -606,6 +608,8 @@ class RuntimeSessionOwner implements RuntimeSession { public constructor(options: RuntimeSessionOptions) { this.createIdentityIssuer = options.createIdentityIssuer; + this.onNavigationDispose = + typeof options.onNavigationDispose === 'function' ? options.onNavigationDispose : undefined; this.onDisposalError = options.onDisposalError; this.scope = new OwnerScope(options.onDisposalError); this.interfaces = options.interfaces ?? EMPTY_INTERFACES; @@ -730,6 +734,11 @@ class RuntimeSessionOwner implements RuntimeSession { }, this.onDisposalError ); + const onNavigationDispose = this.onNavigationDispose; + if (onNavigationDispose) { + const navigationGeneration = navigation.generation; + navigation.onDispose('navigation-lifecycle', () => onNavigationDispose(navigationGeneration)); + } navigationReference.current = navigation; return navigation; } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 743aa6eee..f4e2ade95 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -160,6 +160,10 @@ export interface SlotService { } export interface SlotServiceOptions { + readonly disposeCommittedArtifact?: ( + navigationGeneration: object, + registeredSlotId: string + ) => void; readonly googletag: GoogletagAdapter; readonly now?: () => number; readonly reconciliation?: SlotReconciliationBoundary; @@ -203,6 +207,7 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; + artifactRetirementAttempted: boolean; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; lastResponseIdentifier: string | undefined; @@ -629,6 +634,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const placementQuarantine = new Map(); const quarantinedKeysByPhysical = new WeakMap(); const now = options.now ?? (() => performance.now()); + const disposeCommittedArtifact = + typeof options.disposeCommittedArtifact === 'function' + ? options.disposeCommittedArtifact + : undefined; let reconciliationBoundary: SlotReconciliationBoundary | undefined; let reconciliationObserve: SlotReconciliationBoundary['observe'] | undefined; let reconciliationIsConnected: SlotReconciliationBoundary['isConnected'] | undefined; @@ -847,6 +856,16 @@ export function createSlotService(options: SlotServiceOptions): SlotService { invokeIntent(record, queued); }; + const retireCommittedArtifact = (record: InternalSlotRecord, physical: PhysicalSlot): void => { + if (physical.artifactRetirementAttempted) return; + physical.artifactRetirementAttempted = true; + try { + disposeCommittedArtifact?.(record.state.owner.generation, record.view.registeredSlotId); + } catch { + // Physical retirement remains authoritative when artifact cleanup throws. + } + }; + const prepareReplacementCommit = ( record: InternalSlotRecord, oldPhysical: PhysicalSlot, @@ -866,6 +885,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (existing) throw new GoogletagReplacementCandidateCollisionError(replacement); const physical: PhysicalSlot = { activeCycle: undefined, + artifactRetirementAttempted: false, definition, domElement, destroyAttempted: false, @@ -907,6 +927,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { addSetValue(physicalSlots, physical); if (!setHasValue(physicalSlots, physical)) return false; if (record.state.disposed || !record.state.owner.isCurrent()) return false; + retireCommittedArtifact(record, oldPhysical); + if (record.state.disposed || !record.state.owner.isCurrent()) return false; record.physical = physical; oldPhysical.record = undefined; deleteSetValue(physicalSlots, oldPhysical); @@ -924,6 +946,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!orphanedSlot || orphanedSlot === source.slot) return; const orphan: PhysicalSlot = { activeCycle: undefined, + artifactRetirementAttempted: true, definition: source.definition, domElement: undefined, destroyAttempted: true, @@ -979,6 +1002,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) ); } catch { + retireCommittedArtifact(record, physical); physical.state = 'quarantined'; failQueued(record, 'gpt_request_failed'); return; @@ -994,6 +1018,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; void operation.result.then( (result) => { + retireCommittedArtifact(record, physical); if (result.status !== 'replaced') { detachDestroyedOld(); failQueued(record, 'gpt_request_failed'); @@ -1007,6 +1032,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { advanceQueued(record); }, (error: unknown) => { + retireCommittedArtifact(record, physical); physical.state = 'quarantined'; const replacementError = error instanceof GoogletagReplacementError ? error : undefined; const reusedOldIdentity = replacementError?.orphanedSlot === physical.slot; @@ -1069,12 +1095,17 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } const physical = intent.record.physical; - if (physical?.activeCycle?.intent === intent) { - physical.activeCycle.intent = undefined; + const physicalCycle = physical?.activeCycle; + const ownsPhysicalCycle = physicalCycle?.intent === intent; + if (ownsPhysicalCycle && physical && physicalCycle) { + physicalCycle.intent = undefined; physical.state = 'quarantined'; physical.quarantineReason = 'completion'; } settle(intent, failed('gpt_completion_timeout')); + if (ownsPhysicalCycle && physical?.ownership === 'trusted_server') { + recoverRequestTimeout(intent.record, physical); + } }; const armRequestDeadline = (intent: RequestIntent): void => { @@ -1341,6 +1372,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const retirePhysicalForNavigation = (physical: PhysicalSlot): void => { + const record = physical.record; + if (record) retireCommittedArtifact(record, physical); physical.record = undefined; if (physical.ownership === 'publisher') { if (physical.activeCycle) { @@ -1440,6 +1473,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (record.physical !== physical || physical.ownership !== 'trusted_server') return; settleReconciliationWork(record, physical, reason); + retireCommittedArtifact(record, physical); record.physical = undefined; physical.record = undefined; physical.state = 'retired'; @@ -1490,6 +1524,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ) { return false; } + retireCommittedArtifact(record, window.orphan); window.terminal = true; clearReconciliationTimers(window); window.operation?.dispose(); @@ -2038,6 +2073,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } const physical: PhysicalSlot = { activeCycle: undefined, + artifactRetirementAttempted: false, definition, domElement, destroyAttempted: false, @@ -2578,6 +2614,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); + if (record) retireCommittedArtifact(record, physical); if (record?.physical === physical) record.physical = undefined; physical.record = undefined; physical.activeCycle = undefined; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 0a7af0c7b..b10334550 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -729,6 +729,44 @@ describe('browser composition', () => { expect(session?.currentNavigation?.currentAuctionProjection).toEqual(projection); expect(Object.isFrozen(session?.currentNavigation?.currentAuctionProjection)).toBe(true); + const initialNavigation = session?.currentNavigation; + const artifactBatch = initialNavigation?.createAuctionBatch('accepted-artifact'); + const artifactOwner = artifactBatch?.createRenderAttempt('accepted-artifact-slot'); + const artifactStore = session?.interfaces['artifacts'] as + Parameters[0]['artifacts'] | undefined; + if (!artifactOwner?.ok || !artifactStore || !reservationService) { + throw new Error('Expected accepted-artifact dependencies'); + } + const acceptedSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
accepted
', + width: 300, + height: 250, + }); + const acceptedAttempt = createRenderAttempt({ + artifacts: artifactStore, + owner: artifactOwner.value, + prepareRenderSource: () => acceptedSource, + reservations: reservationService, + }); + if (!acceptedAttempt.ok) throw new Error(acceptedAttempt.reason); + const disposeAcceptedArtifact = vi.fn(); + const acceptedArtifact = Object.freeze({ + kind: 'direct_iframe' as const, + attemptId: acceptedAttempt.value.id, + slot: acceptedAttempt.value.slot, + navigationGeneration: acceptedAttempt.value.navigationGeneration, + dispose: disposeAcceptedArtifact, + }); + expect( + acceptedAttempt.value.admitDirectWinner(acceptedSource, Object.freeze({ selectedCpm: 1 })) + ).toBe(true); + expect(acceptedAttempt.value.beginDirect()).toBe(true); + expect(acceptedAttempt.value.beginAdm(acceptedArtifact)).toBe(true); + expect(acceptedAttempt.value.accept()).toBe(true); + expect(artifactStore.current('accepted-artifact-slot')).toBe(acceptedArtifact); + projection.auction.auctionId = 'publisher-mutated'; expect( ( @@ -740,6 +778,8 @@ describe('browser composition', () => { const replacement = session?.replaceNavigation(); expect(replacement).toMatchObject({ ok: true }); if (!replacement?.ok) throw new Error('Expected SPA navigation'); + expect(disposeAcceptedArtifact).toHaveBeenCalledOnce(); + expect(artifactStore.current('accepted-artifact-slot')).toBeUndefined(); expect(replacement.value.currentAuctionProjection).toBeUndefined(); expect(composition.runtimeSessionForTest()).toBe(session); expect(composition.reservationServiceForTest()).toBe(reservationService); diff --git a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts index 4fc6e31ca..5f305a647 100644 --- a/crates/trusted-server-js/lib/test/kernel/sessions.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/sessions.test.ts @@ -29,6 +29,25 @@ function frozenProjection(id: string): Readonly { } describe('runtime and navigation sessions', () => { + it('reports every navigation generation exactly once at its disposal boundary', () => { + const onNavigationDispose = vi.fn(); + const runtime = createRuntimeSession({ + createIdentityIssuer: identityFactory(), + onNavigationDispose, + }); + const initial = runtime.startInitialNavigation(frozenProjection('initial')); + if (!initial.ok) throw new Error('Expected initial navigation'); + + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error('Expected replacement navigation'); + expect(onNavigationDispose).toHaveBeenCalledExactlyOnceWith(initial.value.generation); + + runtime.dispose(); + runtime.dispose(); + expect(onNavigationDispose).toHaveBeenCalledTimes(2); + expect(onNavigationDispose).toHaveBeenLastCalledWith(replacement.value.generation); + }); + it('owns one current navigation and replaces it atomically before reverse disposal', () => { const order: string[] = []; const runtime = createRuntimeSession({ createIdentityIssuer: identityFactory() }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index b4a583829..27ca7772c 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -627,8 +627,12 @@ describe('navigation-owned DOM reconciliation', () => { vi.setSystemTime(0); const gpt = createGptHarness(); const dom = createReconciliationBoundary(); + const disposeCommittedArtifact = vi.fn(() => { + throw new Error('fictional artifact cleanup failure'); + }); dom.put('slot-div', {}); const service = createSlotService({ + disposeCommittedArtifact, googletag: gpt.adapter, now: () => Date.now(), reconciliation: dom.boundary, @@ -650,6 +654,7 @@ describe('navigation-owned DOM reconciliation', () => { [[300, 250]], 'slot-div' ); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); const request = service.request({ intentId: 'after-rebind', @@ -743,6 +748,33 @@ describe('navigation-owned DOM reconciliation', () => { expect(gpt.defineSlot).not.toHaveBeenCalled(); }); + it('releases the exact committed artifact before retiring a failed reconciliation', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const disposeCommittedArtifact = vi.fn(); + dom.put('slot-div', {}); + const service = createSlotService({ + disposeCommittedArtifact, + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(5_000); + + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact.mock.invocationCallOrder[0]).toBeLessThan( + gpt.destroySlots.mock.invocationCallOrder[0] as number + ); + expect(gpt.destroySlots).toHaveBeenCalledOnce(); + }); + it('commits a unique replacement found only by the final 5,000 ms pass', async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -2421,12 +2453,16 @@ describe('physical GPT cycles', () => { }); }); - it('keeps a completion-timeout cycle quarantined until its exact late completion drains', async () => { + it('recovers a completion timeout through the exact destroy/redefine transaction', async () => { vi.useFakeTimers(); const harness = createGptHarness(); - const service = createSlotService({ googletag: harness.adapter }); + const disposeCommittedArtifact = vi.fn(); + const service = createSlotService({ + disposeCommittedArtifact, + googletag: harness.adapter, + }); const navigation = createNavigation(); - const slot = bindTrustedSlot(service, navigation); + const oldSlot = bindTrustedSlot(service, navigation); const first = service.request({ intentId: 'completion-timeout', navigationGeneration: navigation.generation, @@ -2435,24 +2471,21 @@ describe('physical GPT cycles', () => { registeredSlotId: 'slot', }); await Promise.resolve(); - service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRequested', { slot: oldSlot }); await vi.advanceTimersByTimeAsync(10_000); await expect(first.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); - const blocked = service.request({ - intentId: 'blocked', - navigationGeneration: navigation.generation, - operation: 'refresh', - requestClass: 'primary', - registeredSlotId: 'slot', - }); - await expect(blocked.result).resolves.toMatchObject({ reason: 'slot_quarantined' }); + await Promise.resolve(); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected completion-timeout replacement'); + } + expect(harness.destroySlots).toHaveBeenCalledExactlyOnceWith([oldSlot]); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); - service.handleGptEvent('slotRequested', { slot }); - expect(service.snapshotForTest().cycles).toBe(1); service.handleGptEvent('slotRenderEnded', { isEmpty: false, responseIdentifier: 'late-completion', - slot, + slot: oldSlot, }); const recovered = service.request({ intentId: 'recovered', @@ -2463,7 +2496,20 @@ describe('physical GPT cycles', () => { }); await Promise.resolve(); expect(recovered.status).toBe('active'); - recovered.dispose(); + expect(harness.refresh).toHaveBeenLastCalledWith( + [replacement], + Object.freeze({ changeCorrelator: false }) + ); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'replacement-completion', + slot: replacement, + }); + await expect(recovered.result).resolves.toEqual({ + responseIdentifier: 'replacement-completion', + status: 'rendered', + }); }); it('never releases publisher request-timeout quarantine from later GPT events', async () => { @@ -3294,6 +3340,10 @@ describe('Task 11 adversarial ownership review', () => { await expect(request.result).resolves.toMatchObject({ reason: 'gpt_completion_timeout' }); expect(service.snapshotForTest().cycles).toBe(0); + const replacement = harness.defineSlot.mock.results[0]?.value; + if (typeof replacement !== 'object' || replacement === null) { + throw new Error('Expected handler-enforced timeout replacement'); + } const next = service.request({ intentId: 'after-late-exact-completion', @@ -3304,8 +3354,8 @@ describe('Task 11 adversarial ownership review', () => { }); await Promise.resolve(); expect(harness.refresh).toHaveBeenCalledTimes(2); - service.handleGptEvent('slotRequested', { slot }); - service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + service.handleGptEvent('slotRequested', { slot: replacement }); + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot: replacement }); await expect(next.result).resolves.toMatchObject({ status: 'rendered' }); }); From f7e2dc0b90d9d59a2220d6d2e84435f870a65081 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:35:11 -0700 Subject: [PATCH 335/494] Prepare the transactional Prebid module --- .../lib/src/composition/browser.ts | 10 +- .../lib/src/integrations/prebid/module.ts | 118 +++++++++++++ .../lib/test/composition/browser.test.ts | 34 +++- .../test/integrations/prebid/module.test.ts | 157 ++++++++++++++++++ 4 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/prebid/module.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index a4c16a404..1df7a28a6 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -159,6 +159,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; + readonly prebidStartupForTest?: (config: unknown) => void; } interface AcceptedBrowserBoot { @@ -254,6 +255,8 @@ export function createTestBrowserRuntimeComposition( const providedBindings = runtimeOptions.getBindings; const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); const gptRuntime = Object.freeze({ start: startGpt }); + const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); + const prebidRuntime = Object.freeze({ start: startPrebid }); let runtimeSession: RuntimeSession | undefined; const getBindings: NonNullable = (id) => { const provided = providedBindings?.(id); @@ -659,7 +662,12 @@ export function createTestBrowserRuntimeComposition( const session = createRuntimeSession({ createIdentityIssuer: compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, - interfaces: Object.freeze({ adapters: composition.adapters, gpt: gptRuntime, ...services }), + interfaces: Object.freeze({ + adapters: composition.adapters, + gpt: gptRuntime, + prebid: prebidRuntime, + ...services, + }), onNavigationDispose: (navigationGeneration) => artifacts.disposeNavigation(navigationGeneration), }); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts new file mode 100644 index 000000000..0d1ab8c1f --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -0,0 +1,118 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +export const PREBID_INTEGRATION_ID = 'prebid' as const; + +const MAX_CONFIG_DEPTH = 16; +const MAX_CONFIG_NODES = 512; +const MAX_CONFIG_MEMBERS = 256; +const arrayIsArrayIntrinsic = Array.isArray; +const numberIsFiniteIntrinsic = Number.isFinite; +const objectGetOwnPropertyDescriptorIntrinsic = Object.getOwnPropertyDescriptor; +const objectGetOwnPropertyNamesIntrinsic = Object.getOwnPropertyNames; +const objectGetOwnPropertySymbolsIntrinsic = Object.getOwnPropertySymbols; +const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; +const objectIsFrozenIntrinsic = Object.isFrozen; + +interface PrebidIntegrationRuntime { + readonly start: (config: unknown) => void; +} + +function validFrozenConfig(candidate: unknown): boolean { + const seen = new Set(); + let nodes = 0; + const visit = (value: unknown, depth: number, topLevel: boolean): boolean => { + if (value === undefined) return topLevel; + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return numberIsFiniteIntrinsic(value); + if (typeof value !== 'object' || depth > MAX_CONFIG_DEPTH || nodes >= MAX_CONFIG_NODES) { + return false; + } + if (seen.has(value) || !objectIsFrozenIntrinsic(value)) return false; + seen.add(value); + nodes += 1; + + const array = arrayIsArrayIntrinsic(value); + const prototype = objectGetPrototypeOfIntrinsic(value); + if ( + (!array && prototype !== Object.prototype && prototype !== null) || + (array && prototype !== Array.prototype) + ) { + return false; + } + if (objectGetOwnPropertySymbolsIntrinsic(value).length !== 0) return false; + const names = objectGetOwnPropertyNamesIntrinsic(value); + if (names.length > MAX_CONFIG_MEMBERS + (array ? 1 : 0)) return false; + if (array) { + const length = objectGetOwnPropertyDescriptorIntrinsic(value, 'length'); + if (!length || !('value' in length) || names.length !== length.value + 1) return false; + for (let index = 0; index < length.value; index += 1) { + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(value, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return false; + if (!visit(descriptor.value, depth + 1, false)) return false; + } + return true; + } + + for (let index = 0; index < names.length; index += 1) { + const name = names[index]; + if (name === undefined) return false; + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(value, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return false; + if (!visit(descriptor.value, depth + 1, false)) return false; + } + return true; + }; + + try { + return visit(candidate, 0, true); + } catch { + return false; + } +} + +function readPrebidRuntime( + interfaces: Readonly> +): PrebidIntegrationRuntime | undefined { + try { + const descriptor = objectGetOwnPropertyDescriptorIntrinsic(interfaces, PREBID_INTEGRATION_ID); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + arrayIsArrayIntrinsic(candidate) || + !objectIsFrozenIntrinsic(candidate) || + Reflect.ownKeys(candidate).length !== 1 + ) { + return undefined; + } + const start = objectGetOwnPropertyDescriptorIntrinsic(candidate, 'start'); + if (!start || !('value' in start) || typeof start.value !== 'function') return undefined; + return candidate as PrebidIntegrationRuntime; + } catch { + return undefined; + } +} + +/** Build the release-bound Prebid module registered by the coordinated runtime. */ +export function createPrebidIntegrationRegistration(release: string): IntegrationRegistration { + return Object.freeze({ + id: PREBID_INTEGRATION_ID, + release, + prepare: ({ config, interfaces }: IntegrationPrepareContext) => { + if (!validFrozenConfig(config)) throw new TypeError('Prebid integration config is invalid'); + const runtime = readPrebidRuntime(interfaces); + if (!runtime) throw new TypeError('Prebid integration runtime is unavailable'); + + return Object.freeze({ + activate: ({ afterCommit }: IntegrationActivationContext) => { + afterCommit(() => runtime.start(config)); + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index b10334550..25497640b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -25,6 +25,7 @@ import { log as localLog } from '../../src/core/log'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; +import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; import { @@ -590,16 +591,21 @@ describe('browser composition', () => { expect(releases).toEqual(['slotRenderEnded', 'slotRequested']); }); - it('injects the GPT module boundary and retains only server-frozen configuration', async () => { + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; - const config = Object.freeze({ scriptUrl: '/integrations/gpt/script' }); - const providedBindings = vi.fn(() => ({ - config, + const gptConfig = Object.freeze({ scriptUrl: '/integrations/gpt/script' }); + const prebidConfig = Object.freeze({ clientSideBidders: Object.freeze(['rubicon']) }); + const providedBindings = vi.fn((id: string) => ({ + config: id === 'prebid' ? prebidConfig : gptConfig, interfaces: Object.freeze({ publisherControlled: Object.freeze({}) }), })); const startGpt = vi.fn((received: unknown) => { - expect(received).toBe(config); + expect(received).toBe(gptConfig); + expect((target as { version?: unknown }).version).toBe('1.0.0'); + }); + const startPrebid = vi.fn((received: unknown) => { + expect(received).toBe(prebidConfig); expect((target as { version?: unknown }).version).toBe('1.0.0'); }); const composition = createTestBrowserRuntimeComposition( @@ -609,9 +615,12 @@ describe('browser composition', () => { manifest: { version: 1, releaseId, - integrations: [{ id: 'gpt', required: true }], + integrations: [ + { id: 'gpt', required: true }, + { id: 'prebid', required: true }, + ], }, - knownIntegrationIds: Object.freeze(['gpt']), + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), boot: { auctionProjection: { version: 1, @@ -632,6 +641,7 @@ describe('browser composition', () => { }, coreActivations: { correctnessGptListeners: vi.fn() }, gptStartupForTest: startGpt, + prebidStartupForTest: startPrebid, } ); @@ -640,10 +650,16 @@ describe('browser composition', () => { expect( composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) ).toBe(true); + expect( + composition.runtime.registerIntegration(createPrebidIntegrationRegistration(releaseId)) + ).toBe(true); await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); - expect(providedBindings).toHaveBeenCalledExactlyOnceWith('gpt'); - expect(startGpt).toHaveBeenCalledExactlyOnceWith(config); + expect(providedBindings).toHaveBeenCalledTimes(2); + expect(providedBindings).toHaveBeenNthCalledWith(1, 'gpt'); + expect(providedBindings).toHaveBeenNthCalledWith(2, 'prebid'); + expect(startGpt).toHaveBeenCalledExactlyOnceWith(gptConfig); + expect(startPrebid).toHaveBeenCalledExactlyOnceWith(prebidConfig); expect(isGuardInstalled()).toBe(true); expect(composition.runtimeSessionForTest()?.interfaces).not.toHaveProperty( 'publisherControlled' diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts new file mode 100644 index 000000000..d15af2ac2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createPrebidIntegrationRegistration } from '../../../src/integrations/prebid/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true })), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ id, release: RELEASE_ID, prepare }); +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional Prebid integration module', () => { + it('prepares inertly and starts the external boundary only after commit', async () => { + const config = Object.freeze({ clientSideBidders: Object.freeze(['rubicon']) }); + const order: string[] = []; + const start = vi.fn((received: unknown) => { + order.push('start'); + expect(received).toBe(config); + }); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid', 'gate']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + }), + }); + registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['gate:prepare', 'core', 'gate:activate', 'publish', 'start', 'drain']); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + if (result.state === 'kernel') result.dispose(); + }); + + it('fails preparation without effects when the composition omits the Prebid boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + }); + registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it.each([ + [ + 'accessor', + Object.freeze( + Object.defineProperty({}, 'externalBundleUrl', { + enumerable: true, + get: () => '/publisher-controlled', + }) + ), + ], + ['mutable nested data', Object.freeze({ nested: {} })], + ['non-plain data', Object.freeze({ value: Object.freeze(new Date(0)) })], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + }), + }); + registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); + }); + + it('isolates post-commit startup failure to the Prebid module', async () => { + const start = vi.fn(() => { + throw new Error('fictional Prebid startup failure'); + }); + const runtimeFailures: unknown[] = []; + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + }), + }); + registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'prebid', phase: 'after_commit' }], + }); + expect(start).toHaveBeenCalledTimes(1); + expect(runtimeFailures).toEqual([{ id: 'prebid', phase: 'after_commit' }]); + }); +}); From a1f0f00be2eb9e110f56e3dd9b1ffefbaa5be658 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:41:42 -0700 Subject: [PATCH 336/494] Publish Prebid bids transactionally --- .../lib/src/integrations/prebid/module.ts | 232 ++++++++++++++++++ .../test/integrations/prebid/module.test.ts | 216 +++++++++++++++- 2 files changed, 447 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 0d1ab8c1f..0b2bc4d59 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -1,8 +1,17 @@ +import { + isRendererReservationIdV1, + ownDataObject, + validBoundedString, + validDimension, +} from '../../core/contracts/auction_projection'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; import type { IntegrationActivationContext, IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; +import type { NavigationSession } from '../../kernel/sessions'; +import type { ReservationService } from '../../services/reservations'; export const PREBID_INTEGRATION_ID = 'prebid' as const; @@ -116,3 +125,226 @@ export function createPrebidIntegrationRegistration(release: string): Integratio }, }); } + +/** Exact TS-owned bid passed to the version-pinned Prebid admission boundary. */ +export interface PreparedTrustedBidV1 { + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: Readonly<{ + readonly requestId: string; + readonly adId: string; + readonly cpm: number; + readonly width: number; + readonly height: number; + readonly ad: ''; + readonly ttl: 300; + readonly creativeId: string; + readonly netRevenue: true; + readonly currency: 'USD'; + readonly bidderCode: 'trustedServer'; + readonly meta: Readonly<{ + readonly advertiserDomains: readonly string[]; + readonly tsAuctionId: string; + readonly tsBidId: string; + readonly tsAdmHash?: string; + }>; + }>; +} + +export type PrebidBidPublicationFailureReason = + | 'descriptor_invalid' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'registry_full' + | 'reservation_collision' + | 'winner_not_renderable'; + +export type PrebidBidPublicationResult = + | Readonly<{ ok: true; bid: Readonly }> + | Readonly<{ ok: false; reason: PrebidBidPublicationFailureReason }>; + +type PrebidPublicationNavigation = Pick< + NavigationSession, + 'currentAuctionProjection' | 'generation' | 'isCurrent' | 'onDispose' +>; + +export interface PrebidBidPublicationInput { + readonly admitTrustedBid: (preparedBid: Readonly) => unknown; + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: BrowserAuctionBidV1; + readonly generatedBid: unknown; + readonly navigation: PrebidPublicationNavigation; + readonly reservations: Pick; +} + +function isCurrentProjectedWinner(input: PrebidBidPublicationInput): boolean { + try { + const projection = input.navigation.currentAuctionProjection as + Readonly | undefined; + if ( + !projection || + !objectIsFrozenIntrinsic(projection) || + !objectIsFrozenIntrinsic(projection.auction) || + !objectIsFrozenIntrinsic(projection.auction.results) || + !objectIsFrozenIntrinsic(projection.bids) || + !objectIsFrozenIntrinsic(input.bid) || + !objectIsFrozenIntrinsic(input.bid.targeting) || + !objectIsFrozenIntrinsic(input.bid.renderSource) || + !input.navigation.isCurrent() || + input.auctionId !== projection.auction.auctionId || + input.adUnitCode !== input.bid.slot || + !isRendererReservationIdV1(input.bid.rendererReservationId) + ) { + return false; + } + + let bidMatches = 0; + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === input.bid) bidMatches += 1; + } + if (bidMatches !== 1) return false; + + let winnerMatches = 0; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const result = projection.auction.results[index]; + if ( + result?.outcome === 'winner' && + result.slot === input.bid.slot && + result.candidateId === input.bid.candidateId + ) { + winnerMatches += 1; + } + } + return winnerMatches === 1; + } catch { + return false; + } +} + +function prepareTrustedBid( + input: PrebidBidPublicationInput +): Readonly | undefined { + try { + const generated = ownDataObject(input.generatedBid); + const width = input.bid.renderSource.width; + const height = input.bid.renderSource.height; + if ( + !generated || + !validBoundedString(generated.requestId, 64) || + !validBoundedString(generated.adId, 128) || + !Object.is(generated.cpm, input.bid.cpm) || + generated.width !== width || + generated.height !== height || + !validDimension(width) || + !validDimension(height) + ) { + return undefined; + } + + const advertiserDomains = Object.freeze([] as string[]); + const meta = Object.freeze({ + advertiserDomains, + tsAuctionId: input.auctionId, + tsBidId: input.bid.upstreamBidId, + }); + const creativeId = + input.bid.renderSource.type === 'aps' && input.bid.renderSource.creativeId + ? input.bid.renderSource.creativeId + : input.bid.upstreamBidId; + const bid = Object.freeze({ + requestId: generated.requestId, + adId: input.bid.rendererReservationId, + cpm: input.bid.cpm, + width, + height, + ad: '' as const, + ttl: 300 as const, + creativeId, + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer' as const, + meta, + }); + return Object.freeze({ auctionId: input.auctionId, adUnitCode: input.adUnitCode, bid }); + } catch { + return undefined; + } +} + +function registrationFailure(reason: string): PrebidBidPublicationFailureReason { + if (reason === 'reservation_collision') return 'reservation_collision'; + if (reason === 'registry_full') return 'registry_full'; + if (reason === 'stale_owner' || reason === 'service_disposed') { + return 'winner_not_renderable'; + } + if ( + reason === 'invalid_reservation_id' || + reason === 'invalid_slot' || + reason === 'invalid_render_source' || + reason === 'invalid_winner_context' || + reason === 'prebid_cpm_mismatch' + ) { + return 'descriptor_invalid'; + } + return 'prebid_admission_failed'; +} + +/** Register before exposing one TS-owned bid through the version-pinned Prebid boundary. */ +export function publishPrebidBid(input: PrebidBidPublicationInput): PrebidBidPublicationResult { + if (!isCurrentProjectedWinner(input)) { + return Object.freeze({ ok: false, reason: 'winner_not_renderable' }); + } + const preparedBid = prepareTrustedBid(input); + if (!preparedBid) return Object.freeze({ ok: false, reason: 'descriptor_invalid' }); + + const registration = (() => { + try { + return input.reservations.registerPrebidLease({ + reservationId: input.bid.rendererReservationId, + slot: input.bid.slot, + navigation: input.navigation, + auctionId: input.auctionId, + adUnitCode: input.adUnitCode, + renderSource: input.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: input.bid.cpm }), + prebidBid: preparedBid.bid, + }); + } catch { + return Object.freeze({ ok: false as const, reason: 'service_disposed' as const }); + } + })(); + if (!registration.ok) { + return Object.freeze({ ok: false, reason: registrationFailure(registration.reason) }); + } + + let failure: 'prebid_admission_failed' | 'prebid_contract_violation' | undefined; + try { + const admission = input.admitTrustedBid(preparedBid); + if (admission === 'not_admitted') failure = 'prebid_admission_failed'; + else if (admission !== 'admitted') failure = 'prebid_contract_violation'; + } catch { + failure = 'prebid_admission_failed'; + } + if (!failure) return Object.freeze({ ok: true, bid: preparedBid }); + + const tombstoned = (() => { + try { + return input.reservations.tombstonePrebidLease( + { + reservationId: input.bid.rendererReservationId, + auctionId: input.auctionId, + adUnitCode: input.adUnitCode, + navigationGeneration: input.navigation.generation, + }, + failure + ); + } catch { + return false; + } + })(); + return Object.freeze({ + ok: false, + reason: tombstoned ? failure : 'prebid_contract_violation', + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index d15af2ac2..15fab46be 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; -import { createPrebidIntegrationRegistration } from '../../../src/integrations/prebid/module'; +import { + createPrebidIntegrationRegistration, + publishPrebidBid, + type PrebidBidPublicationInput, + type PreparedTrustedBidV1, +} from '../../../src/integrations/prebid/module'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; import { createIntegrationRegistry, type IntegrationInstallCallbacks, type IntegrationRegistration, } from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; +import { createReservationService } from '../../../src/services/reservations'; const RELEASE_ID = 'a'.repeat(64); @@ -155,3 +163,209 @@ describe('transactional Prebid integration module', () => { expect(runtimeFailures).toEqual([{ id: 'prebid', phase: 'after_commit' }]); }); }); + +describe('ordered Prebid bid publication', () => { + function preparePublication() { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(1); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const reservationId = `r1_${'a'.repeat(22)}`; + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
private creative
', + width: 300, + height: 250, + }); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'aps', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource, + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + expect(navigation.installAuctionProjection(projection)).toBe(true); + const reservations = createReservationService({ + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as typeof renderSource) + : undefined, + }); + const generatedBid = Object.freeze({ + requestId: 'prebid-request-one', + adId: 'prebid-generated-id', + cpm: bid.cpm, + width: 300, + height: 250, + }); + const order: string[] = []; + const admitTrustedBid = vi.fn((_preparedBid: Readonly) => { + order.push('admit'); + expect(reservations.recognize(reservationId)).toMatchObject({ + recognized: true, + state: 'awaiting_prebid_selection', + }); + return 'admitted' as const; + }); + const input: PrebidBidPublicationInput = { + admitTrustedBid, + auctionId: 'auction-one', + adUnitCode: bid.slot, + bid, + generatedBid, + navigation, + reservations: { + registerPrebidLease: (registrationInput) => { + order.push('reservation'); + return reservations.registerPrebidLease(registrationInput); + }, + tombstonePrebidLease: reservations.tombstonePrebidLease, + }, + }; + return { + admitTrustedBid, + bid, + generatedBid, + input, + navigation, + order, + reservationId, + reservations, + runtime, + }; + } + + it('registers the lease before exposing one capability-free frozen bid', () => { + const publication = preparePublication(); + + const result = publishPrebidBid(publication.input); + + expect(result.ok).toBe(true); + expect(publication.order).toEqual(['reservation', 'admit']); + expect(publication.admitTrustedBid).toHaveBeenCalledTimes(1); + const prepared = publication.admitTrustedBid.mock.calls[0]?.[0]; + if (!prepared) throw new Error('Expected prepared bid'); + expect(prepared).toMatchObject({ + auctionId: 'auction-one', + adUnitCode: 'slot-one', + bid: { + requestId: 'prebid-request-one', + adId: publication.reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'upstream-one', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: { + advertiserDomains: [], + tsAuctionId: 'auction-one', + tsBidId: 'upstream-one', + }, + }, + }); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared.bid)).toBe(true); + expect(Object.isFrozen(prepared.bid.meta)).toBe(true); + expect(JSON.stringify(prepared)).not.toContain('private creative'); + expect(publication.generatedBid.adId).toBe('prebid-generated-id'); + publication.runtime.dispose(); + }); + + it.each([ + ['not admitted', () => 'not_admitted' as const, 'prebid_admission_failed'], + [ + 'throw', + () => { + throw new Error('fictional Prebid failure'); + }, + 'prebid_admission_failed', + ], + ['partial publication', () => 'partially_admitted', 'prebid_contract_violation'], + ])('tombstones an admission that reports %s', (_caseName, admission, reason) => { + const publication = preparePublication(); + + expect(publishPrebidBid({ ...publication.input, admitTrustedBid: admission })).toEqual({ + ok: false, + reason, + }); + expect(publication.reservations.recognize(publication.reservationId)).toMatchObject({ + recognized: true, + state: reason, + }); + publication.runtime.dispose(); + }); + + it('fails before exposure on collision and leaves the generated identity untouched', () => { + const publication = preparePublication(); + expect( + publication.reservations.registerPrebidLease({ + reservationId: publication.reservationId, + slot: publication.bid.slot, + navigation: publication.navigation, + auctionId: 'auction-one', + adUnitCode: publication.bid.slot, + renderSource: publication.bid.renderSource, + winnerContext: Object.freeze({ selectedCpm: publication.bid.cpm }), + prebidBid: Object.freeze({ cpm: publication.bid.cpm }), + }) + ).toMatchObject({ ok: true }); + + expect(publishPrebidBid(publication.input)).toEqual({ + ok: false, + reason: 'reservation_collision', + }); + expect(publication.admitTrustedBid).not.toHaveBeenCalled(); + expect(publication.generatedBid.adId).toBe('prebid-generated-id'); + publication.runtime.dispose(); + }); + + it('rejects a stale projected bid and malformed generated response before registration', () => { + const stale = preparePublication(); + expect(publishPrebidBid({ ...stale.input, auctionId: 'other-auction' })).toEqual({ + ok: false, + reason: 'winner_not_renderable', + }); + expect(stale.order).toEqual([]); + stale.runtime.dispose(); + + const malformed = preparePublication(); + expect(publishPrebidBid({ ...malformed.input, generatedBid: { cpm: 1.25 } })).toEqual({ + ok: false, + reason: 'descriptor_invalid', + }); + expect(malformed.order).toEqual([]); + malformed.runtime.dispose(); + }); +}); From 32c8b8a4585be192a40903f19115d9718f74e36f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:43:32 -0700 Subject: [PATCH 337/494] Observe publisher GPT calls transactionally --- .../lib/src/adapters/googletag.ts | 234 +++++++++++++++++- .../lib/test/adapters/googletag.test.ts | 137 ++++++++++ .../lib/test/composition/browser.test.ts | 2 + .../lib/test/services/slots.test.ts | 2 + 4 files changed, 374 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 7fa0124b3..08d461eda 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -83,6 +83,48 @@ export interface GoogletagTargetingObserver { readonly beforePublisherMutation: (slot: object, key?: string) => void; } +/** One publisher-originated GPT call observed outside Trusted Server operations. */ +export interface GoogletagPublisherCallObserver { + readonly defineSlot?: ( + call: Readonly + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }>; + readonly destroySlots?: (call: Readonly) => void; + readonly display?: ( + call: Readonly + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; + readonly refresh?: ( + call: Readonly + ) => + | Readonly<{ action: 'forward' }> + | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'suppress' }>; +} + +/** Narrow data supplied before one publisher `defineSlot` call. */ +export interface GoogletagPublisherDefineSlotCall { + readonly adUnitPath: unknown; + readonly elementId: unknown; + readonly initialLoadDisabled: boolean; + readonly sizes: unknown; +} + +/** Narrow data supplied after one successful publisher `destroySlots` call. */ +export interface GoogletagPublisherDestroySlotsCall { + readonly slots: readonly object[]; +} + +/** Narrow data supplied before one publisher `display` call. */ +export interface GoogletagPublisherDisplayCall { + readonly initialLoadDisabled: boolean; + readonly target: unknown; +} + +/** Narrow data supplied before one publisher `refresh` call. */ +export interface GoogletagPublisherRefreshCall { + readonly requestedSlots: readonly object[] | undefined; + readonly slots: readonly object[]; +} + /** The small GPT surface exposed to an accepted operation. */ export interface GoogletagFacade { bindingToken(): object; @@ -122,6 +164,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; + observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; run( command: (googletag: Readonly) => T, options?: GoogletagOperationOptions @@ -750,6 +793,7 @@ export function createBrowserGoogletagAdapter( let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; + let trustedCallDepth = 0; const markFirstDisplay = (): void => { if (firstDisplayObserved) return; @@ -1481,7 +1525,13 @@ export function createBrowserGoogletagAdapter( return; } try { - const value = operation.command(facade); + trustedCallDepth += 1; + let value: unknown; + try { + value = operation.command(facade); + } finally { + trustedCallDepth -= 1; + } if (operation.settled) return; if (disposed) { fail(operation, 'operation_disposed'); @@ -1744,8 +1794,190 @@ export function createBrowserGoogletagAdapter( return handle; }; + const observePublisherCalls = (observer: GoogletagPublisherCallObserver): (() => void) => { + if (disposed) throw new GoogletagAdapterError('operation_disposed'); + if (typeof observer !== 'object' || observer === null) { + throw new TypeError('GPT publisher observer must be an object'); + } + const current = currentBinding(); + if (current.status !== 'present') { + return (): void => undefined; + } + const service = Reflect.apply(current.value.pubads, current.value.binding, []); + if ((typeof service !== 'object' || service === null) && typeof service !== 'function') { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const serviceObject = service as object; + const currentBindingObject = current.value.binding; + const observerMethod = ( + key: Key + ): GoogletagPublisherCallObserver[Key] | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(observer, key); + if (!descriptor) return undefined; + if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new TypeError('GPT publisher observer methods must be own data properties'); + } + if (descriptor.value !== undefined && typeof descriptor.value !== 'function') { + throw new TypeError('GPT publisher observer methods must be functions'); + } + return descriptor.value as GoogletagPublisherCallObserver[Key] | undefined; + }; + const defineObserver = observerMethod('defineSlot'); + const destroyObserver = observerMethod('destroySlots'); + const displayObserver = observerMethod('display'); + const refreshObserver = observerMethod('refresh'); + const tracker = ensureInitialLoadTracking(current.value, serviceObject); + const stillCurrent = (): boolean => + !disposed && + readTarget(target) === currentBindingObject && + Reflect.apply(current.value.pubads, currentBindingObject, []) === serviceObject; + const objectSlots = (candidate: unknown): readonly object[] | undefined => { + if ( + !Array.isArray(candidate) || + candidate.some( + (slot) => (typeof slot !== 'object' || slot === null) && typeof slot !== 'function' + ) + ) { + return undefined; + } + return Object.freeze([...candidate]) as readonly object[]; + }; + const allSlots = (): readonly object[] | undefined => { + const getSlots = safeMember(serviceObject, 'getSlots'); + if (typeof getSlots !== 'function') return undefined; + try { + return objectSlots(Reflect.apply(getSlots, serviceObject, [])); + } catch { + return undefined; + } + }; + const restorers: Array<() => void> = []; + const install = ( + external: object, + key: PropertyKey, + mediate: ( + original: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ) => unknown + ): void => { + const original = safeMember(external, key); + if (typeof original !== 'function') return; + const callable = original as (...arguments_: unknown[]) => unknown; + const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + if (trustedCallDepth > 0 || !stillCurrent()) { + return Reflect.apply(callable, this, arguments_); + } + return mediate(callable, this, arguments_); + }; + const restore = replaceMethod(external, key, wrapper, stillCurrent); + if (!restore) throw new GoogletagAdapterError('external_artifact_incompatible'); + restorers[restorers.length] = restore; + }; + try { + install(currentBindingObject, 'defineSlot', (original, receiver, arguments_) => { + if (!defineObserver || arguments_.length !== 3) { + return Reflect.apply(original, receiver, arguments_); + } + try { + const decision = defineObserver( + Object.freeze({ + adUnitPath: arguments_[0], + sizes: arguments_[1], + elementId: arguments_[2], + initialLoadDisabled: tracker?.disabled === true, + }) + ); + if ( + decision?.action === 'handoff' && + ((typeof decision.slot === 'object' && decision.slot !== null) || + typeof decision.slot === 'function') + ) { + return decision.slot; + } + } catch { + // Observer failure must leave the publisher call native. + } + return Reflect.apply(original, receiver, arguments_); + }); + install(currentBindingObject, 'display', (original, receiver, arguments_) => { + if (displayObserver && arguments_.length === 1) { + try { + const decision = displayObserver( + Object.freeze({ + target: arguments_[0], + initialLoadDisabled: tracker?.disabled === true, + }) + ); + if (decision?.action === 'suppress') return undefined; + } catch { + // Observer failure must leave the publisher call native. + } + } + return Reflect.apply(original, receiver, arguments_); + }); + install(serviceObject, 'refresh', (original, receiver, arguments_) => { + if (refreshObserver && arguments_.length <= 2) { + const requested = arguments_[0] === undefined ? undefined : objectSlots(arguments_[0]); + const effective = requested ?? (arguments_[0] === undefined ? allSlots() : undefined); + if (effective) { + try { + const decision = refreshObserver( + Object.freeze({ requestedSlots: requested, slots: effective }) + ); + if (decision?.action === 'suppress') return undefined; + if (decision?.action === 'replace') { + const replacement = objectSlots(decision.slots); + if (replacement) { + return Reflect.apply(original, receiver, [replacement, ...arguments_.slice(1)]); + } + } + } catch { + // Observer failure must leave the publisher call native. + } + } + } + return Reflect.apply(original, receiver, arguments_); + }); + install(currentBindingObject, 'destroySlots', (original, receiver, arguments_) => { + let destroyedSlots: readonly object[] | undefined; + if (arguments_.length === 0 || (arguments_.length === 1 && arguments_[0] === undefined)) { + destroyedSlots = allSlots(); + } else if (arguments_.length === 1) { + destroyedSlots = objectSlots(arguments_[0]); + } + const result = Reflect.apply(original, receiver, arguments_); + if (result === true && destroyedSlots && destroyObserver) { + try { + destroyObserver(Object.freeze({ slots: destroyedSlots })); + } catch { + // Post-call bookkeeping cannot alter the publisher return value. + } + } + return result; + }); + } catch (error) { + for (let index = restorers.length - 1; index >= 0; index -= 1) restorers[index]?.(); + throw error; + } + let released = false; + const release = (): void => { + if (released) return; + released = true; + try { + deleteSetValue(effects, release); + } catch { + // Exact wrapper restoration still runs when bookkeeping is hostile. + } + for (let index = restorers.length - 1; index >= 0; index -= 1) restorers[index]?.(); + }; + registerAdapterEffect(release); + return release; + }; + return Object.freeze({ bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + observePublisherCalls, run, notifyReady, dispose: (): void => { diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index ed496a76c..dce6d1c01 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -37,6 +37,8 @@ function createReadyGoogletag( return commands.length; }), }, + defineSlot: vi.fn(), + destroySlots: vi.fn(), display, getConfig: vi.fn((key: string) => key === 'disableInitialLoad' ? { disableInitialLoad: initialLoad.disabled } : {} @@ -1919,6 +1921,141 @@ describe('browser googletag adapter readiness', () => { expect(targeting.has('hb_adid')).toBe(false); }); + it('exposes one reversible publisher-call observer without changing ordinary calls', () => { + const ready = createReadyGoogletag(); + const nativeDisplay = ready.googletag.display; + const nativeRefresh = ready.pubads.refresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const boundary = adapter as unknown as { + observePublisherCalls?: (observer: object) => () => void; + }; + + expect(boundary.observePublisherCalls).toBeTypeOf('function'); + if (!boundary.observePublisherCalls) return; + + const release = boundary.observePublisherCalls(Object.freeze({})); + expect(ready.googletag.display).not.toBe(nativeDisplay); + expect(ready.pubads.refresh).not.toBe(nativeRefresh); + + release(); + expect(ready.googletag.display).toBe(nativeDisplay); + expect(ready.pubads.refresh).toBe(nativeRefresh); + }); + + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { + const ready = createReadyGoogletag({ initialLoadDisabled: true }); + const handoffSlot = Object.freeze({ id: 'handoff' }); + const ordinarySlot = Object.freeze({ id: 'ordinary' }); + const refreshOptions = Object.freeze({ changeCorrelator: true, publisher: 'kept' }); + const defineReceiver = Object.freeze({ receiver: 'define' }); + const refreshReceiver = Object.freeze({ receiver: 'refresh' }); + const order: string[] = []; + const nativeDefineSlot = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:define'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:display'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:refresh'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeDestroy = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:destroy'); + return arguments_[0] === 'throw' + ? (() => { + throw new Error('publisher destroy failed'); + })() + : true; + }); + Object.assign(ready.googletag, { + defineSlot: nativeDefineSlot, + destroySlots: nativeDestroy, + display: nativeDisplay, + }); + ready.pubads.refresh = nativeRefresh; + ready.pubads.getSlots.mockReturnValue([handoffSlot, ordinarySlot]); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let suppressDisplay = true; + const destroyed: Array = []; + const release = adapter.observePublisherCalls({ + defineSlot: (call) => { + order.push('observer:define'); + expect(call.initialLoadDisabled).toBe(true); + return call.elementId === 'handoff-id' + ? Object.freeze({ action: 'handoff' as const, slot: handoffSlot }) + : Object.freeze({ action: 'forward' as const }); + }, + destroySlots: (call) => { + order.push('observer:destroy'); + destroyed.push(call.slots); + }, + display: () => { + order.push('observer:display'); + if (!suppressDisplay) return Object.freeze({ action: 'forward' as const }); + suppressDisplay = false; + return Object.freeze({ action: 'suppress' as const }); + }, + refresh: (call) => { + order.push('observer:refresh'); + expect(call.requestedSlots).toBeUndefined(); + expect(call.slots).toEqual([handoffSlot, ordinarySlot]); + return Object.freeze({ action: 'replace' as const, slots: Object.freeze([ordinarySlot]) }); + }, + }); + + const defineSlot = ready.googletag.defineSlot as unknown as ( + ...arguments_: unknown[] + ) => unknown; + expect( + Reflect.apply(defineSlot, defineReceiver, ['/publisher', [300, 250], 'handoff-id']) + ).toBe(handoffSlot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + const forwarded = Reflect.apply(defineSlot, defineReceiver, [ + '/publisher', + [728, 90], + 'ordinary-id', + 'publisher-extra', + ]); + expect(forwarded).toEqual({ + arguments_: ['/publisher', [728, 90], 'ordinary-id', 'publisher-extra'], + receiver: defineReceiver, + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(display, defineReceiver, ['handoff-id'])).toBeUndefined(); + expect(Reflect.apply(display, defineReceiver, ['handoff-id', 'publisher-extra'])).toEqual({ + arguments_: ['handoff-id', 'publisher-extra'], + receiver: defineReceiver, + }); + + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(refresh, refreshReceiver, [undefined, refreshOptions])).toEqual({ + arguments_: [[ordinarySlot], refreshOptions], + receiver: refreshReceiver, + }); + + const destroySlots = ready.googletag.destroySlots as unknown as ( + slots?: readonly object[] + ) => unknown; + expect(destroySlots([handoffSlot])).toBe(true); + expect(destroyed).toEqual([[handoffSlot]]); + expect(order).toEqual([ + 'observer:define', + 'native:define', + 'observer:display', + 'native:display', + 'observer:refresh', + 'native:refresh', + 'native:destroy', + 'observer:destroy', + ]); + + release(); + }); + it('tracks native GPT initial-load configuration without duplicate wrappers', async () => { const ready = createReadyGoogletag({ initialLoadDisabled: true }); const nativeSetConfig = ready.googletag.setConfig; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 25497640b..3df0f52fa 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -89,6 +89,7 @@ function synchronousGptAdapter() { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => Value) => { let result: Promise; try { @@ -537,6 +538,7 @@ describe('browser composition', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { const result = Promise.resolve(command(facade)); return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 27ca7772c..1f260c66d 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -134,6 +134,7 @@ function createGptHarness( bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let disposed = false; const dispose = vi.fn(() => { @@ -3687,6 +3688,7 @@ describe('Task 11 adversarial ownership review', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let value: T; try { From 7afa7b9445e983168dbc42df3bc750476dedddae Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:45:56 -0700 Subject: [PATCH 338/494] Scope Prebid queries to event callbacks --- .../lib/src/adapters/prebid.ts | 58 ++++++++++++++----- .../lib/test/adapters/prebid.test.ts | 28 ++++++++- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index a0d204e27..6075b122b 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -64,6 +64,11 @@ export interface PrebidArtifactRequirements { }>[]; } +/** Read-only Prebid queries valid only while one subscribed event callback is active. */ +export interface PrebidEventFacade { + highestBids(adUnitCode?: string): readonly object[]; +} + /** The small Prebid surface exposed to an accepted operation. */ export interface PrebidFacade { addAdUnits(adUnits: readonly unknown[]): unknown; @@ -72,7 +77,10 @@ export interface PrebidFacade { registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; - subscribe(eventType: string, listener: (event: unknown) => void): () => void; + subscribe( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ): () => void; } /** Options owned by one Prebid operation. */ @@ -548,6 +556,24 @@ export function createBrowserPrebidAdapter( return result; }; + const highestBids = ( + binding: PresentPrebid, + adUnitCode: string | undefined, + isCurrent: () => boolean + ): readonly object[] => { + const value = callBound( + binding, + 'getHighestCpmBids', + adUnitCode === undefined ? [] : [adUnitCode], + isCurrent + ); + if (!Array.isArray(value) || value.some((bid) => typeof bid !== 'object' || bid === null)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + if (!isCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); + return Object.freeze([...value]); + }; + const createFacade = ( binding: PresentPrebid, registerOperationEffect: (disposeEffect: () => void) => () => void, @@ -557,19 +583,8 @@ export function createBrowserPrebidAdapter( Object.freeze({ addAdUnits: (adUnits: readonly unknown[]): unknown => callBound(binding, 'addAdUnits', [[...adUnits]], isOperationCurrent), - highestBids: (adUnitCode?: string): readonly object[] => { - const value = callBound( - binding, - 'getHighestCpmBids', - adUnitCode === undefined ? [] : [adUnitCode], - isOperationCurrent - ); - if (!Array.isArray(value) || value.some((bid) => typeof bid !== 'object' || bid === null)) { - throw new PrebidAdapterError('external_artifact_incompatible'); - } - if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); - return Object.freeze([...value]); - }, + highestBids: (adUnitCode?: string): readonly object[] => + highestBids(binding, adUnitCode, isOperationCurrent), processQueue: (): unknown => callBound(binding, 'processQueue', [], isOperationCurrent), registerBidAdapter: (adapter: unknown, bidderCode: string, spec?: object): unknown => callBound( @@ -582,7 +597,10 @@ export function createBrowserPrebidAdapter( callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => callBound(binding, 'requestBids', [options], isOperationCurrent), - subscribe: (eventType: string, listener: (event: unknown) => void): (() => void) => { + subscribe: ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ): (() => void) => { if (!isOperationCurrent()) throw new PrebidAdapterError('external_artifact_incompatible'); const add = safeMember(binding.binding, 'onEvent'); if (!isOperationCurrent() || typeof add !== 'function') @@ -592,10 +610,18 @@ export function createBrowserPrebidAdapter( throw new PrebidAdapterError('external_artifact_incompatible'); const wrapped = (event: unknown): void => { if (!isBindingCurrent()) return; + let callbackActive = true; + const isEventCurrent = (): boolean => callbackActive && isBindingCurrent(); + const eventFacade: Readonly = Object.freeze({ + highestBids: (adUnitCode?: string): readonly object[] => + highestBids(binding, adUnitCode, isEventCurrent), + }); try { - listener(event); + listener(event, eventFacade); } catch { // Publisher callbacks cannot escape the Prebid boundary. + } finally { + callbackActive = false; } }; let attempted = false; diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 0b6108924..e9933ec82 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createBrowserPrebidAdapter } from '../../src/adapters/prebid'; +import { createBrowserPrebidAdapter, type PrebidEventFacade } from '../../src/adapters/prebid'; type Command = () => void; @@ -958,6 +958,32 @@ describe('browser Prebid adapter readiness', () => { expect(first.listeners.get('bidResponse')?.size).toBe(0); }); + it('grants synchronous highest-bid access only for the active event callback', async () => { + const ready = createReadyPrebid(); + const selected = Object.freeze({ adId: 'r1_selected', adUnitCode: 'slot-one' }); + ready.pbjs.getHighestCpmBids.mockReturnValue([selected]); + const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + let eventFacade: Readonly | undefined; + const listener = vi.fn((event: unknown, prebid: Readonly) => { + eventFacade = prebid; + expect(event).toEqual({ auctionId: 'auction-one' }); + expect(Object.isFrozen(prebid)).toBe(true); + expect(Reflect.ownKeys(prebid)).toEqual(['highestBids']); + expect(prebid.highestBids('slot-one')).toEqual([selected]); + }); + + await adapter.run((prebid) => prebid.subscribe('auctionEnd', listener)).result; + const installed = [...(ready.listeners.get('auctionEnd') ?? [])][0]; + expect(() => installed?.({ auctionId: 'auction-one' })).not.toThrow(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(ready.pbjs.getHighestCpmBids).toHaveBeenCalledExactlyOnceWith('slot-one'); + expect(() => eventFacade?.highestBids('slot-one')).toThrowError( + expect.objectContaining({ code: 'external_artifact_incompatible' }) + ); + adapter.dispose(); + }); + it('rolls back a Prebid listener when installation disposes and cleanup throws', async () => { const ready = createReadyPrebid(); const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); From fd09a6f8ad96e16d313f7bc5e0048fe3445f9f49 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:47:14 -0700 Subject: [PATCH 339/494] Tombstone unselected Prebid groups --- crates/trusted-server-js/lib/src/services/reservations.ts | 6 +++--- .../lib/test/services/reservations.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/reservations.ts b/crates/trusted-server-js/lib/src/services/reservations.ts index 49ba225f7..75bb8c52f 100644 --- a/crates/trusted-server-js/lib/src/services/reservations.ts +++ b/crates/trusted-server-js/lib/src/services/reservations.ts @@ -357,7 +357,7 @@ export interface ReservationService { readonly tombstone: (input: ReservationTombstoneInput, state: 'disposed' | 'stale') => boolean; readonly tombstonePrebidGroup: ( input: PrebidGroupOwnerInput, - state: 'aborted' | 'prebid_selection_timeout' + state: 'aborted' | 'prebid_selection_timeout' | 'unselected' ) => number; readonly tombstonePrebidLease: ( input: PrebidLeaseOwnerInput, @@ -439,8 +439,8 @@ function validPrebidLeaseTombstoneState( function validPrebidGroupTombstoneState( value: unknown -): value is 'aborted' | 'prebid_selection_timeout' { - return value === 'aborted' || value === 'prebid_selection_timeout'; +): value is 'aborted' | 'prebid_selection_timeout' | 'unselected' { + return value === 'aborted' || value === 'prebid_selection_timeout' || value === 'unselected'; } function winnerContext(value: unknown): WinnerContext | undefined { diff --git a/crates/trusted-server-js/lib/test/services/reservations.test.ts b/crates/trusted-server-js/lib/test/services/reservations.test.ts index b7446c5ad..fa34691dd 100644 --- a/crates/trusted-server-js/lib/test/services/reservations.test.ts +++ b/crates/trusted-server-js/lib/test/services/reservations.test.ts @@ -1726,7 +1726,7 @@ describe('Prebid admission leases and selection', () => { expect(service.recognize(reservationId(2))).toMatchObject({ state: 'unselected' }); }); - it.each(['aborted', 'prebid_selection_timeout'] as const)( + it.each(['aborted', 'prebid_selection_timeout', 'unselected'] as const)( 'tombstones %s leases only through their original admission expiry', (reason) => { let now = 25; From 665539ea786407264ca2df963f3c319a1bcb370e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:57:40 -0700 Subject: [PATCH 340/494] Admit Trusted Server bids through Prebid --- .../lib/src/adapters/prebid.ts | 440 ++++++++++++++++++ .../lib/test/adapters/prebid.test.ts | 231 ++++++++- 2 files changed, 670 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 6075b122b..bd4bfb7e8 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -39,6 +39,58 @@ export class PrebidAdapterError extends Error { } } +/** A version-pinned response callback exposed some, but not all, bid state. */ +export class PrebidAdmissionContractError extends Error { + public readonly code = 'prebid_partial_publication'; + public readonly cause: unknown; + + public constructor(cause?: unknown) { + super('prebid_partial_publication'); + this.name = 'PrebidAdmissionContractError'; + this.cause = cause; + } +} + +/** Exact capability-free TS bid accepted by the version-pinned adapter boundary. */ +export interface PreparedTrustedBidV1 { + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: Readonly<{ + readonly requestId: string; + readonly adId: string; + readonly cpm: number; + readonly width: number; + readonly height: number; + readonly ad: ''; + readonly ttl: 300; + readonly creativeId: string; + readonly netRevenue: true; + readonly currency: 'USD'; + readonly bidderCode: string; + readonly meta: Readonly<{ + readonly advertiserDomains: readonly string[]; + readonly tsAuctionId: string; + readonly tsBidId: string; + readonly tsAdmHash?: string; + }>; + }>; +} + +export type PrebidTrustedBidAdmissionResult = 'admitted' | 'not_admitted'; + +/** One exact bidder request owned by a captured Prebid auction callback. */ +export interface PrebidTrustedServerBidRequestV1 { + readonly adUnitCode: string; + readonly requestId: string; +} + +/** Private request delivered by the custom TS bidder adapter. */ +export interface PrebidTrustedServerAuctionV1 { + readonly auctionId: string; + readonly bids: readonly PrebidTrustedServerBidRequestV1[]; + complete(): void; +} + /** The exact recursively frozen external Prebid artifact stamp. */ export interface ExternalPrebidArtifactV1 { readonly abi: 1; @@ -75,6 +127,9 @@ export interface PrebidFacade { highestBids(adUnitCode?: string): readonly object[]; processQueue(): unknown; registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; + registerTrustedServerBidder( + listener: (auction: Readonly) => void + ): unknown; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; subscribe( @@ -98,6 +153,7 @@ export interface PrebidOperation { /** Narrow Prebid boundary consumed by kernel sessions and services. */ export interface PrebidAdapter { bindingStatus(): PrebidBindingStatus; + admitTrustedBid(preparedBid: Readonly): PrebidTrustedBidAdmissionResult; run( command: (prebid: Readonly) => T, options?: PrebidOperationOptions @@ -148,6 +204,18 @@ interface PendingOperation { readonly provisionalEffects: ProvisionalEffect[]; } +interface ActiveTrustedServerAdmission { + readonly addBidResponse: (...arguments_: unknown[]) => unknown; + readonly binding: PresentPrebid; + readonly requests: readonly PrebidTrustedServerBidRequestV1[]; + readonly admittedIds: Set; + readonly admittedRequests: Set; + readonly attemptedRequests: Set; + readonly registration: object; + readonly violatedRequests: Set; + complete(): void; +} + const encoder = new TextEncoder(); function validUnicodeScalars(value: string): boolean { @@ -384,8 +452,79 @@ function validateStamp( } } +function validatePreparedBid(candidate: unknown): Readonly | undefined { + try { + const prepared = frozenRecordValues(candidate, ['auctionId', 'adUnitCode', 'bid']); + if ( + !prepared || + !validString(prepared.auctionId, 128) || + !validString(prepared.adUnitCode, 256) + ) { + return undefined; + } + const bid = frozenRecordValues(prepared.bid, [ + 'requestId', + 'adId', + 'cpm', + 'width', + 'height', + 'ad', + 'ttl', + 'creativeId', + 'netRevenue', + 'currency', + 'bidderCode', + 'meta', + ]); + if ( + !bid || + !validString(bid.requestId, 128) || + typeof bid.adId !== 'string' || + !/^r1_[A-Za-z0-9_-]{22}$/u.test(bid.adId) || + typeof bid.cpm !== 'number' || + !Number.isFinite(bid.cpm) || + bid.cpm < 0 || + typeof bid.width !== 'number' || + !Number.isInteger(bid.width) || + bid.width < 1 || + bid.width > 4096 || + typeof bid.height !== 'number' || + !Number.isInteger(bid.height) || + bid.height < 1 || + bid.height > 4096 || + bid.ad !== '' || + bid.ttl !== 300 || + !validString(bid.creativeId, 256) || + bid.netRevenue !== true || + bid.currency !== 'USD' || + !validString(bid.bidderCode, MAX_NAME_BYTES) + ) { + return undefined; + } + const metaKeys = Object.prototype.hasOwnProperty.call(bid.meta, 'tsAdmHash') + ? ['advertiserDomains', 'tsAuctionId', 'tsBidId', 'tsAdmHash'] + : ['advertiserDomains', 'tsAuctionId', 'tsBidId']; + const meta = frozenRecordValues(bid.meta, metaKeys); + const advertiserDomains = meta && frozenArrayValues(meta.advertiserDomains, 16); + if ( + !meta || + !advertiserDomains || + advertiserDomains.some((domain) => !validString(domain, 256)) || + meta.tsAuctionId !== prepared.auctionId || + !validString(meta.tsBidId, 256) || + (meta.tsAdmHash !== undefined && !validString(meta.tsAdmHash, 128)) + ) { + return undefined; + } + return candidate as Readonly; + } catch { + return undefined; + } +} + const REQUIRED_API_METHODS = [ 'addAdUnits', + 'getBidResponsesForAdUnitCode', 'getHighestCpmBids', 'offEvent', 'onEvent', @@ -469,6 +608,8 @@ export function createBrowserPrebidAdapter( const pending: PendingOperation[] = []; const live = new Set>(); const effects = new Set<() => void>(); + const activeAdmissions = new Map(); + const trustedBidderRegistrations = new Map(); let armedBindings = new WeakSet(); let diagnosedBindings = new WeakSet(); let diagnosedUnbound = false; @@ -556,6 +697,197 @@ export function createBrowserPrebidAdapter( return result; }; + const bidderRequestSnapshot = ( + candidate: unknown + ): + | Readonly<{ + auctionId: string; + bids: readonly PrebidTrustedServerBidRequestV1[]; + }> + | undefined => { + try { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + const auctionId = safeOwnDescriptor(candidate, 'auctionId'); + const bidsDescriptor = safeOwnDescriptor(candidate, 'bids'); + if ( + !auctionId || + !Object.prototype.hasOwnProperty.call(auctionId, 'value') || + !validString(auctionId.value, 128) || + !bidsDescriptor || + !Object.prototype.hasOwnProperty.call(bidsDescriptor, 'value') || + !Array.isArray(bidsDescriptor.value) || + bidsDescriptor.value.length === 0 || + bidsDescriptor.value.length > 256 + ) { + return undefined; + } + const requests: PrebidTrustedServerBidRequestV1[] = []; + const identities = new Set(); + for (const rawBid of bidsDescriptor.value as unknown[]) { + if (typeof rawBid !== 'object' || rawBid === null || Array.isArray(rawBid)) + return undefined; + const adUnitCode = safeOwnDescriptor(rawBid, 'adUnitCode'); + const requestId = safeOwnDescriptor(rawBid, 'bidId'); + if ( + !adUnitCode || + !Object.prototype.hasOwnProperty.call(adUnitCode, 'value') || + !validString(adUnitCode.value, 256) || + !requestId || + !Object.prototype.hasOwnProperty.call(requestId, 'value') || + !validString(requestId.value, 128) + ) { + return undefined; + } + const identity = `${adUnitCode.value}\u0000${requestId.value}`; + if (identities.has(identity)) return undefined; + identities.add(identity); + requests.push(Object.freeze({ adUnitCode: adUnitCode.value, requestId: requestId.value })); + } + return Object.freeze({ auctionId: auctionId.value, bids: Object.freeze(requests) }); + } catch { + return undefined; + } + }; + + const responseCount = ( + binding: PresentPrebid, + adUnitCode: string, + adId: string, + requestId: string, + isCurrent: () => boolean + ): number => { + const response = callBound(binding, 'getBidResponsesForAdUnitCode', [adUnitCode], isCurrent); + if (typeof response !== 'object' || response === null || Array.isArray(response)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const bids = safeMember(response, 'bids'); + if (!Array.isArray(bids)) throw new PrebidAdapterError('external_artifact_incompatible'); + let matches = 0; + for (const bid of bids) { + if (typeof bid !== 'object' || bid === null) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + if ( + safeMember(bid, 'adId') === adId && + safeMember(bid, 'requestId') === requestId && + safeMember(bid, 'adUnitCode') === adUnitCode + ) { + matches += 1; + } + } + return matches; + }; + + const admitTrustedBid = ( + candidate: Readonly + ): PrebidTrustedBidAdmissionResult => { + if (disposed) throw new PrebidAdapterError('operation_disposed'); + const prepared = validatePreparedBid(candidate); + if (!prepared) return 'not_admitted'; + const context = activeAdmissions.get(prepared.auctionId); + if (!context) return 'not_admitted'; + if (!sameBinding(context.binding)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const requestIdentity = `${prepared.adUnitCode}\u0000${prepared.bid.requestId}`; + if ( + !context.requests.some( + (request) => + request.adUnitCode === prepared.adUnitCode && request.requestId === prepared.bid.requestId + ) + ) { + return 'not_admitted'; + } + if ( + context.admittedIds.has(prepared.bid.adId) || + context.admittedRequests.has(requestIdentity) || + context.violatedRequests.has(requestIdentity) + ) { + throw new PrebidAdmissionContractError(); + } + if (context.attemptedRequests.has(requestIdentity)) return 'not_admitted'; + const isCurrent = (): boolean => !disposed && sameBinding(context.binding); + const before = responseCount( + context.binding, + prepared.adUnitCode, + prepared.bid.adId, + prepared.bid.requestId, + isCurrent + ); + if (before !== 0) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(); + } + context.attemptedRequests.add(requestIdentity); + + let responseEvents = 0; + const responseListener = (event: unknown): void => { + if ( + typeof event === 'object' && + event !== null && + safeMember(event, 'adId') === prepared.bid.adId && + safeMember(event, 'requestId') === prepared.bid.requestId && + safeMember(event, 'adUnitCode') === prepared.adUnitCode + ) { + responseEvents += 1; + } + }; + callBound(context.binding, 'onEvent', ['bidResponse', responseListener], isCurrent); + let callbackFailure: unknown; + try { + const mutableBid = { + ...prepared.bid, + meta: { + ...prepared.bid.meta, + advertiserDomains: [...prepared.bid.meta.advertiserDomains], + }, + }; + Reflect.apply(context.addBidResponse, undefined, [prepared.adUnitCode, mutableBid]); + } catch (error) { + callbackFailure = error; + } + let cleanupFailure: unknown; + try { + callBound(context.binding, 'offEvent', ['bidResponse', responseListener], isCurrent); + } catch (error) { + cleanupFailure = error; + } + let after: number; + try { + after = responseCount( + context.binding, + prepared.adUnitCode, + prepared.bid.adId, + prepared.bid.requestId, + isCurrent + ); + } catch (error) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(error); + } + if (cleanupFailure !== undefined) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(cleanupFailure); + } + if (callbackFailure !== undefined) { + if (responseEvents !== 0 || after !== 0) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(callbackFailure); + } + throw callbackFailure; + } + if (responseEvents === 0 && after === 0) return 'not_admitted'; + if (responseEvents !== 1 || after !== 1) { + context.violatedRequests.add(requestIdentity); + throw new PrebidAdmissionContractError(); + } + context.admittedIds.add(prepared.bid.adId); + context.admittedRequests.add(requestIdentity); + return 'admitted'; + }; + const highestBids = ( binding: PresentPrebid, adUnitCode: string | undefined, @@ -574,6 +906,109 @@ export function createBrowserPrebidAdapter( return Object.freeze([...value]); }; + const registerTrustedServerBidder = ( + binding: PresentPrebid, + listener: (auction: Readonly) => void, + registerOperationEffect: (disposeEffect: () => void) => () => void, + isOperationCurrent: () => boolean + ): unknown => { + if (typeof listener !== 'function') { + throw new TypeError('Trusted Server bidder listener must be a function'); + } + if (trustedBidderRegistrations.has(binding.binding)) { + throw new PrebidAdapterError('external_artifact_incompatible'); + } + const registration = Object.freeze({}); + trustedBidderRegistrations.set(binding.binding, registration); + let active = true; + const completeRegistrationAuctions = (): void => { + for (const context of [...activeAdmissions.values()]) { + if (context.registration !== registration) continue; + context.complete(); + } + }; + let release: () => void; + try { + release = registerOperationEffect(() => { + active = false; + if (trustedBidderRegistrations.get(binding.binding) === registration) { + trustedBidderRegistrations.delete(binding.binding); + } + completeRegistrationAuctions(); + }); + } catch (error) { + if (trustedBidderRegistrations.get(binding.binding) === registration) { + trustedBidderRegistrations.delete(binding.binding); + } + throw error; + } + const bidder = Object.freeze({ + callBids: (rawRequest: unknown, rawAddBidResponse: unknown, rawDone: unknown): void => { + const done = typeof rawDone === 'function' ? rawDone : undefined; + let completed = false; + const finish = (): void => { + if (completed) return; + completed = true; + try { + Reflect.apply(done ?? (() => undefined), undefined, []); + } catch { + // Prebid completion cannot escape the registered adapter boundary. + } + }; + const request = bidderRequestSnapshot(rawRequest); + if ( + !active || + !sameBinding(binding) || + !request || + typeof rawAddBidResponse !== 'function' || + !done || + activeAdmissions.has(request.auctionId) + ) { + finish(); + return; + } + const context: ActiveTrustedServerAdmission = { + addBidResponse: rawAddBidResponse as (...arguments_: unknown[]) => unknown, + binding, + requests: request.bids, + admittedIds: new Set(), + admittedRequests: new Set(), + attemptedRequests: new Set(), + registration, + violatedRequests: new Set(), + complete: (): void => { + if (activeAdmissions.get(request.auctionId) !== context) return; + activeAdmissions.delete(request.auctionId); + finish(); + }, + }; + activeAdmissions.set(request.auctionId, context); + const auction = Object.freeze({ + auctionId: request.auctionId, + bids: request.bids, + complete: context.complete, + }); + try { + listener(auction); + } catch { + context.complete(); + } + }, + }); + const bidderFactory = (): Readonly => bidder; + try { + return callBound( + binding, + 'registerBidAdapter', + [bidderFactory, 'trustedServer'], + isOperationCurrent + ); + } catch (error) { + release(); + throw error; + } + }; + const createFacade = ( binding: PresentPrebid, registerOperationEffect: (disposeEffect: () => void) => () => void, @@ -593,6 +1028,10 @@ export function createBrowserPrebidAdapter( spec === undefined ? [adapter, bidderCode] : [adapter, bidderCode, spec], isOperationCurrent ), + registerTrustedServerBidder: ( + listener: (auction: Readonly) => void + ): unknown => + registerTrustedServerBidder(binding, listener, registerOperationEffect, isOperationCurrent), renderAd: (targetDocument: object, adId: string): unknown => callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => @@ -1150,6 +1589,7 @@ export function createBrowserPrebidAdapter( }; return Object.freeze({ + admitTrustedBid, bindingStatus: (): PrebidBindingStatus => currentBinding().status, run, notifyReady, diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index e9933ec82..00cfc2982 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -41,6 +41,7 @@ function createReadyPrebid( const listeners = new Map void>>(); const pbjs = { addAdUnits: vi.fn(), + getBidResponsesForAdUnitCode: vi.fn<() => { bids: object[] }>(() => ({ bids: [] })), getHighestCpmBids: vi.fn<() => object[]>(() => []), offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { listeners.get(type)?.delete(listener); @@ -77,7 +78,8 @@ describe('browser Prebid adapter readiness', () => { it('binds an exact valid artifact and exposes a frozen narrow facade', async () => { const ready = createReadyPrebid(); - const adapter = createBrowserPrebidAdapter({ pbjs: ready.pbjs }); + const target: { pbjs: unknown } = { pbjs: ready.pbjs }; + const adapter = createBrowserPrebidAdapter(target); const operation = adapter.run((prebid) => { expect(Object.isFrozen(prebid)).toBe(true); expect('que' in prebid).toBe(false); @@ -1521,3 +1523,230 @@ describe('browser Prebid adapter readiness', () => { await expect(pushOperation?.result).rejects.toBe(pushError); }); }); + +describe('version-pinned Trusted Server bid admission', () => { + const preparedBid = () => + recursivelyFreeze({ + auctionId: 'auction-one', + adUnitCode: 'slot-one', + bid: { + requestId: 'request-one', + adId: 'r1_BwcHBwcHBwcHBwcHBwcHBw', + cpm: 1.25, + width: 300, + height: 250, + ad: '' as const, + ttl: 300 as const, + creativeId: 'creative-one', + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer', + meta: { + advertiserDomains: [] as string[], + tsAuctionId: 'auction-one', + tsBidId: 'bid-one', + }, + }, + }); + + function admissionFixture() { + const ready = createReadyPrebid(); + const stored: object[] = []; + ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation((adUnitCode?: string) => ({ + bids: stored.filter((bid) => (bid as { adUnitCode?: unknown }).adUnitCode === adUnitCode), + })); + const target: { pbjs: unknown } = { pbjs: ready.pbjs }; + const adapter = createBrowserPrebidAdapter(target); + const auctions: unknown[] = []; + const operation = adapter.run((facade) => { + const boundary = facade as unknown as { + registerTrustedServerBidder(listener: (auction: unknown) => void): unknown; + }; + return boundary.registerTrustedServerBidder((auction) => auctions.push(auction)); + }); + const bidderFactory = ready.pbjs.registerBidAdapter.mock.calls[0]?.[0] as + | (() => { + callBids( + request: unknown, + admit: (adUnitCode: string, bid: Record) => void, + done: () => void + ): void; + }) + | undefined; + const bidder = bidderFactory?.(); + expect(ready.pbjs.registerBidAdapter).toHaveBeenCalledWith(bidderFactory, 'trustedServer'); + const done = vi.fn(); + const emitBidResponse = (bid: object): void => { + for (const listener of ready.listeners.get('bidResponse') ?? []) listener(bid); + }; + const admit = vi.fn((adUnitCode: string, bid: Record) => { + const published = { ...bid, adUnitCode }; + stored.push(published); + emitBidResponse(published); + }); + bidder?.callBids( + { + auctionId: 'auction-one', + bids: [{ adUnitCode: 'slot-one', bidId: 'request-one' }], + }, + admit, + done + ); + const boundary = adapter as unknown as { + admitTrustedBid(prepared: ReturnType): 'admitted' | 'not_admitted'; + }; + return { + adapter, + admit, + auctions, + boundary, + done, + emitBidResponse, + operation, + ready, + stored, + target, + }; + } + + it('captures one exact auction callback and admits a mutable copy atomically', async () => { + const fixture = admissionFixture(); + await expect(fixture.operation.result).resolves.toBeUndefined(); + + expect(fixture.auctions).toHaveLength(1); + const auction = fixture.auctions[0] as { + auctionId: string; + bids: readonly { adUnitCode: string; requestId: string }[]; + complete(): void; + }; + expect(Object.isFrozen(auction)).toBe(true); + expect(Object.isFrozen(auction.bids)).toBe(true); + expect(auction).toMatchObject({ + auctionId: 'auction-one', + bids: [{ adUnitCode: 'slot-one', requestId: 'request-one' }], + }); + + const prepared = preparedBid(); + expect(fixture.boundary.admitTrustedBid(prepared)).toBe('admitted'); + expect(fixture.admit).toHaveBeenCalledTimes(1); + const admitted = fixture.admit.mock.calls[0]?.[1]; + expect(admitted).toEqual(prepared.bid); + expect(admitted).not.toBe(prepared.bid); + expect(admitted?.['meta']).not.toBe(prepared.bid.meta); + expect((admitted?.['meta'] as { advertiserDomains?: unknown })?.advertiserDomains).not.toBe( + prepared.bid.meta.advertiserDomains + ); + expect(Object.isFrozen(prepared.bid)).toBe(true); + + auction.complete(); + auction.complete(); + expect(fixture.done).toHaveBeenCalledTimes(1); + }); + + it('returns not_admitted only when neither state nor an event was published', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + fixture.admit.mockImplementation(() => undefined); + + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + expect(fixture.stored).toEqual([]); + }); + + it('makes a request terminal after not_admitted instead of retrying publication', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + fixture.admit.mockImplementation(() => undefined); + + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + fixture.admit.mockImplementation((adUnitCode, bid) => { + const published = { ...bid, adUnitCode }; + fixture.stored.push(published); + fixture.emitBidResponse(published); + }); + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + expect(fixture.admit).toHaveBeenCalledTimes(1); + expect(fixture.stored).toEqual([]); + }); + + it('matches response state and events by exact request and ad-unit identity', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + const prepared = preparedBid(); + fixture.stored.push({ + ...prepared.bid, + requestId: 'other-request', + adUnitCode: prepared.adUnitCode, + }); + fixture.admit.mockImplementation((adUnitCode, bid) => { + fixture.emitBidResponse({ + ...bid, + requestId: 'other-request', + adUnitCode, + }); + const published = { ...bid, adUnitCode }; + fixture.stored.push(published); + fixture.emitBidResponse(published); + }); + + expect(fixture.boundary.admitTrustedBid(prepared)).toBe('admitted'); + }); + + it('refuses a second live Trusted Server bidder registration on the same binding', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + + const duplicate = fixture.adapter.run((prebid) => prebid.registerTrustedServerBidder(vi.fn())); + + await expect(duplicate.result).rejects.toMatchObject({ + code: 'external_artifact_incompatible', + }); + expect(fixture.ready.pbjs.registerBidAdapter).toHaveBeenCalledTimes(1); + fixture.adapter.dispose(); + }); + + it('throws a contract violation for partial publication and an ordinary callback throw otherwise', async () => { + const partial = admissionFixture(); + await partial.operation.result; + partial.admit.mockImplementation((adUnitCode, bid) => + partial.emitBidResponse({ ...bid, adUnitCode }) + ); + + expect(() => partial.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'prebid_partial_publication' }) + ); + + const failed = admissionFixture(); + await failed.operation.result; + const callbackFailure = new Error('fictional response callback failure'); + failed.admit.mockImplementation(() => { + throw callbackFailure; + }); + expect(() => failed.boundary.admitTrustedBid(preparedBid())).toThrow(callbackFailure); + }); + + it('rejects detached requests, duplicate admission, binding replacement, and late use', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + + expect( + fixture.boundary.admitTrustedBid( + recursivelyFreeze({ ...preparedBid(), adUnitCode: 'other-slot' }) + ) + ).toBe('not_admitted'); + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('admitted'); + expect(() => fixture.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'prebid_partial_publication' }) + ); + + const auction = fixture.auctions[0] as { complete(): void }; + auction.complete(); + expect(fixture.boundary.admitTrustedBid(preparedBid())).toBe('not_admitted'); + + const replaced = admissionFixture(); + await replaced.operation.result; + replaced.target.pbjs = createReadyPrebid().pbjs; + expect(() => replaced.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'external_artifact_incompatible' }) + ); + }); +}); From a7139e27c7ce34fc0e82449569cf6fbeb8586cfb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:59:25 -0700 Subject: [PATCH 341/494] Coordinate Prebid winner selection --- .../lib/src/adapters/prebid.ts | 2 +- .../lib/src/integrations/prebid/module.ts | 430 ++++++++++++++++-- .../test/integrations/prebid/module.test.ts | 292 +++++++++++- 3 files changed, 688 insertions(+), 36 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index bd4bfb7e8..bee06c111 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -66,7 +66,7 @@ export interface PreparedTrustedBidV1 { readonly creativeId: string; readonly netRevenue: true; readonly currency: 'USD'; - readonly bidderCode: string; + readonly bidderCode: 'trustedServer'; readonly meta: Readonly<{ readonly advertiserDomains: readonly string[]; readonly tsAuctionId: string; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 0b2bc4d59..0d89904f1 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -5,15 +5,30 @@ import { validDimension, } from '../../core/contracts/auction_projection'; import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import { + PrebidAdmissionContractError, + type PrebidEventFacade, + type PreparedTrustedBidV1, +} from '../../adapters/prebid'; import type { IntegrationActivationContext, IntegrationPrepareContext, IntegrationRegistration, } from '../../kernel/integration_registry'; -import type { NavigationSession } from '../../kernel/sessions'; +import type { + AuctionBatchScope, + NavigationSession, + RenderAttemptScope, +} from '../../kernel/sessions'; +import type { + RenderAttempt, + RenderAttemptCreationResult, + RenderScheduler, +} from '../../services/render'; import type { ReservationService } from '../../services/reservations'; export const PREBID_INTEGRATION_ID = 'prebid' as const; +export type { PreparedTrustedBidV1 } from '../../adapters/prebid'; const MAX_CONFIG_DEPTH = 16; const MAX_CONFIG_NODES = 512; @@ -126,31 +141,6 @@ export function createPrebidIntegrationRegistration(release: string): Integratio }); } -/** Exact TS-owned bid passed to the version-pinned Prebid admission boundary. */ -export interface PreparedTrustedBidV1 { - readonly auctionId: string; - readonly adUnitCode: string; - readonly bid: Readonly<{ - readonly requestId: string; - readonly adId: string; - readonly cpm: number; - readonly width: number; - readonly height: number; - readonly ad: ''; - readonly ttl: 300; - readonly creativeId: string; - readonly netRevenue: true; - readonly currency: 'USD'; - readonly bidderCode: 'trustedServer'; - readonly meta: Readonly<{ - readonly advertiserDomains: readonly string[]; - readonly tsAuctionId: string; - readonly tsBidId: string; - readonly tsAdmHash?: string; - }>; - }>; -} - export type PrebidBidPublicationFailureReason = | 'descriptor_invalid' | 'prebid_admission_failed' @@ -163,10 +153,7 @@ export type PrebidBidPublicationResult = | Readonly<{ ok: true; bid: Readonly }> | Readonly<{ ok: false; reason: PrebidBidPublicationFailureReason }>; -type PrebidPublicationNavigation = Pick< - NavigationSession, - 'currentAuctionProjection' | 'generation' | 'isCurrent' | 'onDispose' ->; +type PrebidPublicationNavigation = NavigationSession; export interface PrebidBidPublicationInput { readonly admitTrustedBid: (preparedBid: Readonly) => unknown; @@ -176,6 +163,10 @@ export interface PrebidBidPublicationInput { readonly generatedBid: unknown; readonly navigation: PrebidPublicationNavigation; readonly reservations: Pick; + readonly trackAdmittedBid: ( + preparedBid: Readonly, + navigation: PrebidPublicationNavigation + ) => boolean; } function isCurrentProjectedWinner(input: PrebidBidPublicationInput): boolean { @@ -323,10 +314,22 @@ export function publishPrebidBid(input: PrebidBidPublicationInput): PrebidBidPub const admission = input.admitTrustedBid(preparedBid); if (admission === 'not_admitted') failure = 'prebid_admission_failed'; else if (admission !== 'admitted') failure = 'prebid_contract_violation'; - } catch { - failure = 'prebid_admission_failed'; + } catch (error) { + failure = + error instanceof PrebidAdmissionContractError + ? 'prebid_contract_violation' + : 'prebid_admission_failed'; + } + if (!failure) { + try { + if (input.trackAdmittedBid(preparedBid, input.navigation)) { + return Object.freeze({ ok: true, bid: preparedBid }); + } + } catch { + // A published bid without selection ownership must stay suppress-only. + } + failure = 'prebid_contract_violation'; } - if (!failure) return Object.freeze({ ok: true, bid: preparedBid }); const tombstoned = (() => { try { @@ -348,3 +351,364 @@ export function publishPrebidBid(input: PrebidBidPublicationInput): PrebidBidPub reason: tombstoned ? failure : 'prebid_contract_violation', }); } + +export interface PrebidSelectionCoordinatorOptions { + readonly activateAttempt: ( + input: Readonly<{ + attempt: RenderAttempt; + owner: RenderAttemptScope; + preparedBid: Readonly; + }> + ) => boolean; + readonly createAttempt: (owner: RenderAttemptScope) => RenderAttemptCreationResult; + readonly reservations: Pick< + ReservationService, + 'promotePrebidSelection' | 'tombstone' | 'tombstonePrebidGroup' + >; + readonly scheduler?: RenderScheduler; +} + +export interface PrebidSelectionCoordinator { + readonly track: ( + preparedBid: Readonly, + navigation: NavigationSession + ) => boolean; + readonly auctionEnded: (event: unknown, prebid: Readonly) => void; + readonly abort: (navigation: NavigationSession, auctionId: string) => void; + readonly dispose: () => void; +} + +interface TrackedPrebidGroup { + readonly adUnitCode: string; + readonly auction: TrackedPrebidAuction; + readonly bids: Map>; + active: boolean; + timer: unknown; +} + +interface TrackedPrebidAuction { + readonly auctionId: string; + readonly batch: AuctionBatchScope; + readonly groups: Map; + readonly navigation: NavigationSession; + active: boolean; + promotedAttempts: number; +} + +const PREBID_SELECTION_TIMEOUT_MS = 10_000; + +function defaultSelectionScheduler(): RenderScheduler { + return Object.freeze({ + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function exactSelectedBid( + candidate: unknown, + group: TrackedPrebidGroup +): Readonly | undefined { + const record = ownDataObject(candidate); + if ( + !record || + record.auctionId !== group.auction.auctionId || + record.adUnitCode !== group.adUnitCode || + typeof record.adId !== 'string' + ) { + return undefined; + } + const prepared = group.bids.get(record.adId); + if (!prepared) return undefined; + const meta = ownDataObject(record.meta); + return record.requestId === prepared.bid.requestId && + Object.is(record.cpm, prepared.bid.cpm) && + record.bidderCode === prepared.bid.bidderCode && + meta?.tsAuctionId === prepared.auctionId && + meta.tsBidId === prepared.bid.meta.tsBidId + ? prepared + : undefined; +} + +/** Own short Prebid-selection leases without exposing reservation state to the artifact. */ +export function createPrebidSelectionCoordinator( + options: PrebidSelectionCoordinatorOptions +): PrebidSelectionCoordinator { + const scheduler = options.scheduler ?? defaultSelectionScheduler(); + const auctions: TrackedPrebidAuction[] = []; + let disposed = false; + + const removeAuction = (auction: TrackedPrebidAuction): void => { + const index = auctions.indexOf(auction); + if (index >= 0) auctions.splice(index, 1); + auction.active = false; + }; + + const clearGroupTimer = (group: TrackedPrebidGroup): void => { + if (group.timer === undefined) return; + const timer = group.timer; + group.timer = undefined; + try { + scheduler.clear(timer); + } catch { + // Timer cleanup cannot weaken reservation suppression. + } + }; + + const finishGroup = ( + group: TrackedPrebidGroup, + state?: 'aborted' | 'prebid_selection_timeout' | 'unselected' + ): void => { + if (!group.active) return; + group.active = false; + clearGroupTimer(group); + if (state) { + try { + options.reservations.tombstonePrebidGroup( + { + auctionId: group.auction.auctionId, + adUnitCode: group.adUnitCode, + navigationGeneration: group.auction.navigation.generation, + }, + state + ); + } catch { + // The bounded reservation service remains the suppression authority. + } + } + group.auction.groups.delete(group.adUnitCode); + if (group.auction.groups.size !== 0) return; + if (group.auction.promotedAttempts === 0) { + try { + group.auction.batch.dispose(); + } catch { + // Navigation disposal remains the final owner of a hostile batch. + } + } + removeAuction(group.auction); + }; + + const findAuction = ( + navigation: NavigationSession, + auctionId: string + ): TrackedPrebidAuction | undefined => { + for (let index = 0; index < auctions.length; index += 1) { + const auction = auctions[index]; + if (auction?.active && auction.navigation === navigation && auction.auctionId === auctionId) { + return auction; + } + } + return undefined; + }; + + const track = ( + preparedBid: Readonly, + navigation: NavigationSession + ): boolean => { + try { + if ( + disposed || + !navigation.isCurrent() || + !Object.isFrozen(preparedBid) || + !Object.isFrozen(preparedBid.bid) || + !isRendererReservationIdV1(preparedBid.bid.adId) + ) { + return false; + } + let auction = findAuction(navigation, preparedBid.auctionId); + let createdAuction = false; + if (!auction) { + const batch = navigation.createAuctionBatch(`prebid:${preparedBid.auctionId}`); + if (!batch) return false; + auction = { + auctionId: preparedBid.auctionId, + batch, + groups: new Map(), + navigation, + active: true, + promotedAttempts: 0, + }; + createdAuction = true; + } + let group = auction.groups.get(preparedBid.adUnitCode); + if (group?.bids.has(preparedBid.bid.adId)) return false; + if (!group) { + group = { + adUnitCode: preparedBid.adUnitCode, + auction, + bids: new Map(), + active: true, + timer: undefined, + }; + group.bids.set(preparedBid.bid.adId, preparedBid); + auction.groups.set(preparedBid.adUnitCode, group); + if (createdAuction) auctions.push(auction); + let timer: unknown; + try { + timer = scheduler.set( + () => finishGroup(group as TrackedPrebidGroup, 'prebid_selection_timeout'), + PREBID_SELECTION_TIMEOUT_MS + ); + if (!group.active) { + try { + scheduler.clear(timer); + } catch { + // The synchronously-fired logical deadline remains terminal. + } + return false; + } + group.timer = timer; + navigation.onDispose('prebid-selection', () => + finishGroup(group as TrackedPrebidGroup, 'aborted') + ); + if (!group.active || !navigation.isCurrent()) { + finishGroup(group, 'aborted'); + return false; + } + } catch { + if (timer !== undefined && group.timer === undefined) { + try { + scheduler.clear(timer); + } catch { + // Failed publication retains no live logical deadline. + } + } + finishGroup(group); + return false; + } + return true; + } + group.bids.set(preparedBid.bid.adId, preparedBid); + return true; + } catch { + return false; + } + }; + + const auctionEnded = (event: unknown, prebid: Readonly): void => { + if (disposed) return; + const record = ownDataObject(event); + if (!record || !validBoundedString(record.auctionId, 128)) return; + const snapshot = auctions.slice(); + for (let auctionIndex = 0; auctionIndex < snapshot.length; auctionIndex += 1) { + const auction = snapshot[auctionIndex]; + if (!auction?.active || auction.auctionId !== record.auctionId) continue; + const groups = [...auction.groups.values()]; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { + const group = groups[groupIndex]; + if (!group?.active) continue; + let highest: readonly object[]; + try { + highest = prebid.highestBids(group.adUnitCode); + } catch { + continue; + } + const selected: Readonly[] = []; + for (let bidIndex = 0; bidIndex < highest.length; bidIndex += 1) { + const match = exactSelectedBid(highest[bidIndex], group); + if (match) selected.push(match); + } + if (selected.length !== 1) { + finishGroup(group, 'unselected'); + continue; + } + const prepared = selected[0]; + if (!prepared) { + finishGroup(group, 'unselected'); + continue; + } + const owner = auction.batch.createRenderAttempt(group.adUnitCode); + if (!owner.ok) { + finishGroup(group, 'unselected'); + continue; + } + const created = options.createAttempt(owner.value); + if (!created.ok) { + owner.value.dispose(); + finishGroup(group, 'unselected'); + continue; + } + const promotion = options.reservations.promotePrebidSelection({ + reservationId: prepared.bid.adId, + auctionId: prepared.auctionId, + adUnitCode: prepared.adUnitCode, + navigationGeneration: auction.navigation.generation, + attempt: owner.value, + prebidBid: prepared.bid, + }); + if (!promotion.ok) { + created.value.fail('prebid_contract_violation'); + finishGroup(group, 'unselected'); + continue; + } + let activated: boolean; + try { + activated = + options.activateAttempt( + Object.freeze({ attempt: created.value, owner: owner.value, preparedBid: prepared }) + ) === true; + } catch { + activated = false; + } + if (!activated) { + try { + options.reservations.tombstone( + { + reservationId: prepared.bid.adId, + slot: prepared.adUnitCode, + navigationGeneration: auction.navigation.generation, + attemptId: owner.value.id, + }, + 'stale' + ); + } catch { + // A failed PUC activation remains terminal at the attempt boundary. + } + created.value.fail('prebid_contract_violation'); + finishGroup(group); + continue; + } + auction.promotedAttempts += 1; + finishGroup(group); + } + } + }; + + const abort = (navigation: NavigationSession, auctionId: string): void => { + const auction = findAuction(navigation, auctionId); + if (!auction) return; + const groups = [...auction.groups.values()]; + for (let index = 0; index < groups.length; index += 1) { + const group = groups[index]; + if (group) finishGroup(group, 'aborted'); + } + }; + + return Object.freeze({ + track, + auctionEnded, + abort, + dispose: (): void => { + if (disposed) return; + disposed = true; + const snapshot = auctions.slice(); + for (let index = 0; index < snapshot.length; index += 1) { + const auction = snapshot[index]; + if (!auction) continue; + const groups = [...auction.groups.values()]; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { + const group = groups[groupIndex]; + if (group) finishGroup(group, 'aborted'); + } + try { + auction.batch.dispose(); + } catch { + // Runtime disposal remains terminal under hostile callbacks. + } + } + auctions.length = 0; + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index 15fab46be..0cc53e947 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import { PrebidAdmissionContractError } from '../../../src/adapters/prebid'; import { + createPrebidSelectionCoordinator, createPrebidIntegrationRegistration, publishPrebidBid, type PrebidBidPublicationInput, @@ -12,7 +14,12 @@ import { type IntegrationInstallCallbacks, type IntegrationRegistration, } from '../../../src/kernel/integration_registry'; -import { createRuntimeSession } from '../../../src/kernel/sessions'; +import { createRuntimeSession, type RenderAttemptScope } from '../../../src/kernel/sessions'; +import { + createCommittedArtifactStore, + createRenderAttempt, + type RenderAttempt, +} from '../../../src/services/render'; import { createReservationService } from '../../../src/services/reservations'; const RELEASE_ID = 'a'.repeat(64); @@ -235,6 +242,10 @@ describe('ordered Prebid bid publication', () => { }); return 'admitted' as const; }); + const trackAdmittedBid = vi.fn(() => { + order.push('track'); + return true; + }); const input: PrebidBidPublicationInput = { admitTrustedBid, auctionId: 'auction-one', @@ -249,6 +260,7 @@ describe('ordered Prebid bid publication', () => { }, tombstonePrebidLease: reservations.tombstonePrebidLease, }, + trackAdmittedBid, }; return { admitTrustedBid, @@ -260,6 +272,7 @@ describe('ordered Prebid bid publication', () => { reservationId, reservations, runtime, + trackAdmittedBid, }; } @@ -269,7 +282,7 @@ describe('ordered Prebid bid publication', () => { const result = publishPrebidBid(publication.input); expect(result.ok).toBe(true); - expect(publication.order).toEqual(['reservation', 'admit']); + expect(publication.order).toEqual(['reservation', 'admit', 'track']); expect(publication.admitTrustedBid).toHaveBeenCalledTimes(1); const prepared = publication.admitTrustedBid.mock.calls[0]?.[0]; if (!prepared) throw new Error('Expected prepared bid'); @@ -303,6 +316,32 @@ describe('ordered Prebid bid publication', () => { publication.runtime.dispose(); }); + it('suppresses a partially published bid or failed selection tracking as a contract violation', () => { + const partial = preparePublication(); + expect( + publishPrebidBid({ + ...partial.input, + admitTrustedBid: () => { + throw new PrebidAdmissionContractError(); + }, + }) + ).toEqual({ ok: false, reason: 'prebid_contract_violation' }); + expect(partial.reservations.recognize(partial.reservationId)).toMatchObject({ + state: 'prebid_contract_violation', + }); + partial.runtime.dispose(); + + const untracked = preparePublication(); + expect(publishPrebidBid({ ...untracked.input, trackAdmittedBid: () => false })).toEqual({ + ok: false, + reason: 'prebid_contract_violation', + }); + expect(untracked.reservations.recognize(untracked.reservationId)).toMatchObject({ + state: 'prebid_contract_violation', + }); + untracked.runtime.dispose(); + }); + it.each([ ['not admitted', () => 'not_admitted' as const, 'prebid_admission_failed'], [ @@ -369,3 +408,252 @@ describe('ordered Prebid bid publication', () => { malformed.runtime.dispose(); }); }); + +describe('Prebid selection coordination', () => { + function prepareSelection(activateResult = true, synchronousTimer = false) { + let now = 0; + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const reservations = createReservationService({ + now: () => now, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm' | 'cache'; version: 1 }>) + : undefined, + }); + const artifacts = createCommittedArtifactStore(); + const attempts: RenderAttempt[] = []; + const promotions: Array> = []; + const attemptOwners: RenderAttemptScope[] = []; + const timers = new Map void>(); + const cleared: object[] = []; + const activateAttempt = vi.fn(() => activateResult); + const coordinator = createPrebidSelectionCoordinator({ + activateAttempt, + createAttempt: (owner) => { + attemptOwners.push(owner); + const result = createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => + typeof candidate === 'object' && candidate !== null && Object.isFrozen(candidate) + ? (candidate as Readonly<{ type: 'aps' | 'adm' | 'cache'; version: 1 }>) + : undefined, + reservations, + }); + if (result.ok) attempts.push(result.value); + return result; + }, + reservations: { + promotePrebidSelection: (input) => { + const result = reservations.promotePrebidSelection(input); + promotions.push(result); + return result; + }, + tombstone: reservations.tombstone, + tombstonePrebidGroup: reservations.tombstonePrebidGroup, + }, + scheduler: { + clear: (handle) => { + cleared.push(handle as object); + timers.delete(handle as object); + }, + set: (callback, milliseconds) => { + expect(milliseconds).toBe(10_000); + const handle = Object.freeze({}); + timers.set(handle, callback); + if (synchronousTimer) callback(); + return handle; + }, + }, + }); + const admitted = (idCharacter: string, adUnitCode = 'slot-one') => { + const reservationId = `r1_${idCharacter.repeat(22)}`; + const bid = Object.freeze({ + requestId: `request-${idCharacter}`, + adId: reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '' as const, + ttl: 300 as const, + creativeId: `creative-${idCharacter}`, + netRevenue: true as const, + currency: 'USD' as const, + bidderCode: 'trustedServer' as const, + meta: Object.freeze({ + advertiserDomains: Object.freeze([] as string[]), + tsAuctionId: 'auction-one', + tsBidId: `bid-${idCharacter}`, + }), + }); + const prepared = Object.freeze({ auctionId: 'auction-one', adUnitCode, bid }); + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: `
${idCharacter}
`, + width: 300, + height: 250, + }); + expect( + reservations.registerPrebidLease({ + reservationId, + slot: adUnitCode, + navigation, + auctionId: prepared.auctionId, + adUnitCode, + renderSource, + winnerContext: Object.freeze({ selectedCpm: bid.cpm }), + prebidBid: bid, + }) + ).toMatchObject({ ok: true }); + expect(coordinator.track(prepared, navigation)).toBe(!synchronousTimer); + return prepared; + }; + return { + admitted, + activateAttempt, + attempts, + attemptOwners, + cleared, + coordinator, + navigation, + promotions, + reservations, + runtime, + setNow: (value: number) => { + now = value; + }, + timers, + }; + } + + it('promotes only the exact selected TS id and suppresses its group losers', () => { + const harness = prepareSelection(); + const selected = harness.admitted('a'); + const losing = harness.admitted('b'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ); + + expect(harness.attempts).toHaveLength(1); + expect(harness.promotions).toEqual([expect.objectContaining({ ok: true })]); + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'renderable', + }); + expect(harness.reservations.recognize(losing.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attemptOwners[0]?.winnerContext).toEqual({ selectedCpm: 1.25 }); + expect(harness.attempts[0]?.winnerContext).toBeUndefined(); + expect(harness.activateAttempt).toHaveBeenCalledTimes(1); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('tombstones a selected reservation when its PUC attempt cannot activate', () => { + const harness = prepareSelection(false); + const selected = harness.admitted('f'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ state: 'stale' }); + expect(harness.attempts[0]?.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'prebid_contract_violation', + }); + harness.runtime.dispose(); + }); + + it('marks the whole TS group unselected when native Prebid wins', () => { + const harness = prepareSelection(); + const losing = harness.admitted('c'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + adId: 'native-prebid-id', + adUnitCode: 'slot-one', + auctionId: 'auction-one', + cpm: 9, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(losing.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toEqual([]); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + + it('times out a missing auctionEnd and cancels the watchdog on navigation disposal', () => { + const timedOut = prepareSelection(); + const bid = timedOut.admitted('d'); + timedOut.setNow(9_999); + expect(timedOut.timers.size).toBe(1); + [...timedOut.timers.values()][0]?.(); + expect(timedOut.reservations.recognize(bid.bid.adId)).toMatchObject({ + state: 'prebid_selection_timeout', + }); + timedOut.runtime.dispose(); + + const disposed = prepareSelection(); + const disposedBid = disposed.admitted('e'); + disposed.runtime.replaceNavigation(); + expect(disposed.reservations.recognize(disposedBid.bid.adId)).toMatchObject({ + state: 'aborted', + }); + expect(disposed.timers).toHaveLength(0); + }); + + it('rolls back a scheduler that invokes the deadline before timer publication returns', () => { + const harness = prepareSelection(true, true); + const bid = harness.admitted('g'); + + expect(harness.reservations.recognize(bid.bid.adId)).toMatchObject({ + state: 'prebid_selection_timeout', + }); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + harness.runtime.dispose(); + }); +}); From 54ea2ef748d9832b50895c2e8a15d9898b135ec2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:02:27 -0700 Subject: [PATCH 342/494] Fail closed during Prebid winner selection --- .../lib/src/integrations/prebid/module.ts | 36 ++++++-- .../test/integrations/prebid/module.test.ts | 89 +++++++++++++++++-- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 0d89904f1..b5672d18f 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -605,6 +605,10 @@ export function createPrebidSelectionCoordinator( } catch { continue; } + if (highest.length !== 1) { + finishGroup(group, 'unselected'); + continue; + } const selected: Readonly[] = []; for (let bidIndex = 0; bidIndex < highest.length; bidIndex += 1) { const match = exactSelectedBid(highest[bidIndex], group); @@ -624,20 +628,34 @@ export function createPrebidSelectionCoordinator( finishGroup(group, 'unselected'); continue; } - const created = options.createAttempt(owner.value); + let created: RenderAttemptCreationResult; + try { + created = options.createAttempt(owner.value); + } catch { + owner.value.dispose(); + finishGroup(group, 'unselected'); + continue; + } if (!created.ok) { owner.value.dispose(); finishGroup(group, 'unselected'); continue; } - const promotion = options.reservations.promotePrebidSelection({ - reservationId: prepared.bid.adId, - auctionId: prepared.auctionId, - adUnitCode: prepared.adUnitCode, - navigationGeneration: auction.navigation.generation, - attempt: owner.value, - prebidBid: prepared.bid, - }); + let promotion: ReturnType; + try { + promotion = options.reservations.promotePrebidSelection({ + reservationId: prepared.bid.adId, + auctionId: prepared.auctionId, + adUnitCode: prepared.adUnitCode, + navigationGeneration: auction.navigation.generation, + attempt: owner.value, + prebidBid: prepared.bid, + }); + } catch { + created.value.fail('prebid_contract_violation'); + finishGroup(group, 'unselected'); + continue; + } if (!promotion.ok) { created.value.fail('prebid_contract_violation'); finishGroup(group, 'unselected'); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index 0cc53e947..883aec893 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -410,7 +410,14 @@ describe('ordered Prebid bid publication', () => { }); describe('Prebid selection coordination', () => { - function prepareSelection(activateResult = true, synchronousTimer = false) { + function prepareSelection( + options: Readonly<{ + activateResult?: boolean; + synchronousTimer?: boolean; + throwCreateAttempt?: boolean; + throwPromotion?: boolean; + }> = {} + ) { let now = 0; const runtime = createRuntimeSession({ createIdentityIssuer: () => @@ -437,10 +444,11 @@ describe('Prebid selection coordination', () => { const attemptOwners: RenderAttemptScope[] = []; const timers = new Map void>(); const cleared: object[] = []; - const activateAttempt = vi.fn(() => activateResult); + const activateAttempt = vi.fn(() => options.activateResult ?? true); const coordinator = createPrebidSelectionCoordinator({ activateAttempt, createAttempt: (owner) => { + if (options.throwCreateAttempt) throw new Error('attempt factory failed'); attemptOwners.push(owner); const result = createRenderAttempt({ artifacts, @@ -456,6 +464,7 @@ describe('Prebid selection coordination', () => { }, reservations: { promotePrebidSelection: (input) => { + if (options.throwPromotion) throw new Error('promotion failed'); const result = reservations.promotePrebidSelection(input); promotions.push(result); return result; @@ -472,7 +481,7 @@ describe('Prebid selection coordination', () => { expect(milliseconds).toBe(10_000); const handle = Object.freeze({}); timers.set(handle, callback); - if (synchronousTimer) callback(); + if (options.synchronousTimer) callback(); return handle; }, }, @@ -517,7 +526,7 @@ describe('Prebid selection coordination', () => { prebidBid: bid, }) ).toMatchObject({ ok: true }); - expect(coordinator.track(prepared, navigation)).toBe(!synchronousTimer); + expect(coordinator.track(prepared, navigation)).toBe(!options.synchronousTimer); return prepared; }; return { @@ -573,7 +582,7 @@ describe('Prebid selection coordination', () => { }); it('tombstones a selected reservation when its PUC attempt cannot activate', () => { - const harness = prepareSelection(false); + const harness = prepareSelection({ activateResult: false }); const selected = harness.admitted('f'); harness.coordinator.auctionEnded( @@ -625,6 +634,38 @@ describe('Prebid selection coordination', () => { harness.runtime.dispose(); }); + it('fails closed when the pinned single-unit winner query is ambiguous', () => { + const harness = prepareSelection(); + const selected = harness.admitted('i'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + Object.freeze({ + adId: 'native-prebid-id', + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + cpm: selected.bid.cpm, + }), + ]), + }) + ); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toEqual([]); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + it('times out a missing auctionEnd and cancels the watchdog on navigation disposal', () => { const timedOut = prepareSelection(); const bid = timedOut.admitted('d'); @@ -646,7 +687,7 @@ describe('Prebid selection coordination', () => { }); it('rolls back a scheduler that invokes the deadline before timer publication returns', () => { - const harness = prepareSelection(true, true); + const harness = prepareSelection({ synchronousTimer: true }); const bid = harness.admitted('g'); expect(harness.reservations.recognize(bid.bid.adId)).toMatchObject({ @@ -656,4 +697,40 @@ describe('Prebid selection coordination', () => { expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); harness.runtime.dispose(); }); + + it.each([ + { failure: 'attempt creation', options: { throwCreateAttempt: true } }, + { failure: 'reservation promotion', options: { throwPromotion: true } }, + ])('fails closed when $failure throws during selection', ({ options }) => { + const harness = prepareSelection(options); + const selected = harness.admitted('h'); + + expect(() => + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: () => + Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]), + }) + ) + ).not.toThrow(); + + expect(harness.reservations.recognize(selected.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts[0]?.snapshot().outcome).toEqual( + options.throwPromotion + ? { outcome: 'failed', reason: 'prebid_contract_violation' } + : undefined + ); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + harness.runtime.dispose(); + }); }); From 94894999801e99aba2b0be4964d73a10262db49e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:03:27 -0700 Subject: [PATCH 343/494] Complete GPT handoff startup integration --- .../lib/src/adapters/googletag.ts | 60 +++- .../lib/src/composition/browser.ts | 13 +- .../lib/src/integrations/gpt/module.ts | 22 +- .../lib/src/integrations/gpt/startup.ts | 53 +++ .../lib/src/services/slots.ts | 340 +++++++++++++++--- .../lib/test/adapters/googletag.test.ts | 74 ++++ .../lib/test/composition/browser.test.ts | 119 ++++++ .../lib/test/integrations/gpt/module.test.ts | 62 +++- .../lib/test/integrations/gpt/startup.test.ts | 58 +++ .../lib/test/services/slots.test.ts | 130 +++++++ 10 files changed, 861 insertions(+), 70 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt/startup.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 08d461eda..0889fb019 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -1799,16 +1799,6 @@ export function createBrowserGoogletagAdapter( if (typeof observer !== 'object' || observer === null) { throw new TypeError('GPT publisher observer must be an object'); } - const current = currentBinding(); - if (current.status !== 'present') { - return (): void => undefined; - } - const service = Reflect.apply(current.value.pubads, current.value.binding, []); - if ((typeof service !== 'object' || service === null) && typeof service !== 'function') { - throw new GoogletagAdapterError('external_artifact_incompatible'); - } - const serviceObject = service as object; - const currentBindingObject = current.value.binding; const observerMethod = ( key: Key ): GoogletagPublisherCallObserver[Key] | undefined => { @@ -1826,6 +1816,56 @@ export function createBrowserGoogletagAdapter( const destroyObserver = observerMethod('destroySlots'); const displayObserver = observerMethod('display'); const refreshObserver = observerMethod('refresh'); + const current = currentBinding(); + if (current.status === 'pending' && current.commandQueue) { + const normalizedObserver: GoogletagPublisherCallObserver = Object.freeze({ + ...(defineObserver ? { defineSlot: defineObserver } : {}), + ...(destroyObserver ? { destroySlots: destroyObserver } : {}), + ...(displayObserver ? { display: displayObserver } : {}), + ...(refreshObserver ? { refresh: refreshObserver } : {}), + }); + let released = false; + let notificationActive = true; + let installedRelease: (() => void) | undefined; + const release = (): void => { + if (released) return; + released = true; + notificationActive = false; + try { + deleteSetValue(effects, release); + } catch { + // Exact deferred restoration still runs when bookkeeping is hostile. + } + installedRelease?.(); + }; + try { + queueCommand(current.commandQueue, () => { + if (!notificationActive || released || disposed) return; + notificationActive = false; + const ready = currentBinding(); + if (ready.status !== 'present') return; + try { + installedRelease = observePublisherCalls(normalizedObserver); + if (released) installedRelease(); + } catch { + // Readiness mediation cannot escape the publisher-owned command queue. + } + }); + } catch (error) { + notificationActive = false; + released = true; + throw error; + } + registerAdapterEffect(release); + return release; + } + if (current.status !== 'present') return (): void => undefined; + const service = Reflect.apply(current.value.pubads, current.value.binding, []); + if ((typeof service !== 'object' || service === null) && typeof service !== 'function') { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const serviceObject = service as object; + const currentBindingObject = current.value.binding; const tracker = ensureInitialLoadTracking(current.value, serviceObject); const stillCurrent = (): boolean => !disposed && diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 1df7a28a6..3b4539f80 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -41,6 +41,7 @@ import { type GptWinnerPublicationInput, type GptWinnerPublicationResult, } from '../integrations/gpt/module'; +import { createGptStartup } from '../integrations/gpt/startup'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; @@ -253,8 +254,17 @@ export function createTestBrowserRuntimeComposition( ): BrowserRuntimeComposition { const composition = createBrowserComposition(compositionOptions); const providedBindings = runtimeOptions.getBindings; + let browserServices: Readonly | undefined; const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); - const gptRuntime = Object.freeze({ start: startGpt }); + const gptRuntime = createGptStartup({ + googletag: composition.adapters.googletag, + slots: () => { + const slots = browserServices?.slots; + if (!slots) throw new Error('GPT slot service is unavailable'); + return slots; + }, + start: startGpt, + }); const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); const prebidRuntime = Object.freeze({ start: startPrebid }); let runtimeSession: RuntimeSession | undefined; @@ -274,7 +284,6 @@ export function createTestBrowserRuntimeComposition( }); }; let preparedBrowserServices: PreparedBrowserServices | undefined; - let browserServices: Readonly | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; let auctionBatchService: AuctionBatchService | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 4b1bfa27e..52d0f62c5 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -45,6 +45,7 @@ const promiseThenIntrinsic = Promise.prototype.then; const reflectApplyIntrinsic = Reflect.apply; interface GptIntegrationRuntime { + readonly activate: () => () => void; readonly start: (config: unknown) => void; } @@ -585,12 +586,22 @@ function readGptRuntime( candidate === null || Array.isArray(candidate) || !Object.isFrozen(candidate) || - Reflect.ownKeys(candidate).length !== 1 + Reflect.ownKeys(candidate).length !== 2 ) { return undefined; } + const activate = Object.getOwnPropertyDescriptor(candidate, 'activate'); const start = Object.getOwnPropertyDescriptor(candidate, 'start'); - if (!start || !('value' in start) || typeof start.value !== 'function') return undefined; + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } return candidate as GptIntegrationRuntime; } @@ -608,6 +619,13 @@ export function createGptIntegrationRegistration(release: string): IntegrationRe activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { // Register restoration before the first live browser mutation. onDispose(resetGuardState); + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const release = runtime.activate(); + if (typeof release !== 'function') { + throw new TypeError('GPT integration activation disposer is unavailable'); + } + runtimeRelease.value = release; installGptGuard(); afterCommit(() => runtime.start(config)); }, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts new file mode 100644 index 000000000..d0f92278b --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts @@ -0,0 +1,53 @@ +import type { + GoogletagAdapter, + GoogletagPublisherCallObserver, + GoogletagPublisherDefineSlotCall, + GoogletagPublisherDestroySlotsCall, + GoogletagPublisherDisplayCall, + GoogletagPublisherRefreshCall, +} from '../../adapters/googletag'; +import type { SlotService } from '../../services/slots'; + +type GptPublisherSlotBoundary = Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' +>; + +export interface GptStartup { + readonly activate: () => () => void; + readonly start: (config: unknown) => void; +} + +export interface GptStartupOptions { + readonly googletag: Pick; + readonly slots: () => GptPublisherSlotBoundary; + readonly start?: (config: unknown) => void; +} + +/** Join the sole GPT interception boundary to runtime-owned slot handoff state. */ +export function createGptStartup(options: GptStartupOptions): GptStartup { + return Object.freeze({ + activate: (): (() => void) => { + const slots = options.slots(); + const observer: GoogletagPublisherCallObserver = Object.freeze({ + defineSlot: (call: Readonly) => + slots.claimPublisherGptSlot(call), + destroySlots: ({ slots: destroyed }: Readonly) => { + for (let index = 0; index < destroyed.length; index += 1) { + const slot = destroyed[index]; + if (slot) slots.recordPublisherDestruction(slot); + } + }, + display: (call: Readonly) => + slots.preparePublisherDisplay(call), + refresh: (call: Readonly) => + slots.preparePublisherRefresh(call), + }); + return options.googletag.observePublisherCalls(observer); + }, + start: (config: unknown): void => options.start?.(config), + }); +} diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index f4e2ade95..c0ee6b796 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -2,6 +2,9 @@ import type { GoogletagAdapter, GoogletagFacade, GoogletagOperation, + GoogletagPublisherDefineSlotCall, + GoogletagPublisherDisplayCall, + GoogletagPublisherRefreshCall, GoogletagReplacementCommitAdmission, GoogletagReplacementDefinition, GoogletagReplacementResult, @@ -60,6 +63,8 @@ export type SlotRegistrationResult = /** Binding metadata required for safe TS-owned replacement. */ export interface GptSlotBinding { readonly definition?: GoogletagReplacementDefinition; + /** Stable configured prefix accepted only for an unambiguous hydration handoff. */ + readonly elementIdPrefix?: string; readonly ownership: GptSlotOwnership; readonly slot: object; } @@ -142,6 +147,18 @@ export interface SlotService { owner: NavigationSession, slots: readonly string[] ) => PreparedProjectionSlots | undefined; + readonly claimPublisherGptSlot: ( + call: GoogletagPublisherDefineSlotCall + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }>; + readonly preparePublisherDisplay: ( + call: GoogletagPublisherDisplayCall + ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; + readonly preparePublisherRefresh: ( + call: GoogletagPublisherRefreshCall + ) => + | Readonly<{ action: 'forward' }> + | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'suppress' }>; readonly projectionRegistry: (owner: NavigationSession) => ProjectionSlotRegistry; readonly recordPublisherDestruction: (slot: object) => boolean; readonly recordPublisherIntent: (slot: object) => boolean; @@ -210,10 +227,14 @@ interface PhysicalSlot { artifactRetirementAttempted: boolean; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; + elementIdPrefix: string | undefined; lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; placementKeys: readonly string[]; publisherIntentCount: number; + publisherElementIds: readonly string[]; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; quarantineReason: 'completion' | 'navigation' | 'request' | undefined; record: InternalSlotRecord | undefined; saturationOwner: boolean; @@ -489,6 +510,30 @@ function copyReplacementSizes(sizes: unknown): unknown | undefined { } } +function replacementSizesEqual(left: unknown, right: unknown): boolean { + const leftCopy = copyReplacementSizes(left); + const rightCopy = copyReplacementSizes(right); + if (!Array.isArray(leftCopy) || !Array.isArray(rightCopy)) return false; + const pair = (value: unknown): value is readonly [number, number] => + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number'; + const normalized = (value: readonly unknown[]): readonly (readonly [number, number])[] => + pair(value) ? Object.freeze([value]) : (value as readonly (readonly [number, number])[]); + const leftPairs = normalized(leftCopy); + const rightPairs = normalized(rightCopy); + if (leftPairs.length !== rightPairs.length) return false; + for (let index = 0; index < leftPairs.length; index += 1) { + const leftPair = leftPairs[index]; + const rightPair = rightPairs[index]; + if (!leftPair || !rightPair || leftPair[0] !== rightPair[0] || leftPair[1] !== rightPair[1]) { + return false; + } + } + return true; +} + function snapshotReplacementDefinition(input: unknown): GoogletagReplacementDefinition | undefined { if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined; let adUnitPath: unknown; @@ -888,16 +933,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { artifactRetirementAttempted: false, definition, domElement, + elementIdPrefix: oldPhysical.elementIdPrefix, destroyAttempted: false, lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: oldPhysical.placementKeys, publisherIntentCount: 0, + publisherElementIds: Object.freeze([]), quarantineReason: undefined, record, saturationOwner: false, slot: replacement, state: 'live', + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, }; let committed = false; const rollback = (): void => { @@ -949,16 +998,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { artifactRetirementAttempted: true, definition: source.definition, domElement: undefined, + elementIdPrefix: source.elementIdPrefix, destroyAttempted: true, lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: source.placementKeys, publisherIntentCount: 0, + publisherElementIds: Object.freeze([]), quarantineReason: 'request', record: undefined, saturationOwner: false, slot: orphanedSlot, state: 'quarantined', + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, }; try { setWeakMapValue(physicalByObject, orphan.slot, orphan); @@ -1978,10 +2031,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { let slot: unknown; let ownership: unknown; let externalDefinition: unknown; + let externalElementIdPrefix: unknown; try { slot = binding.slot; ownership = binding.ownership; externalDefinition = binding.definition; + externalElementIdPrefix = binding.elementIdPrefix; } catch { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } @@ -1994,6 +2049,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (ownership !== 'publisher' && ownership !== 'trusted_server') { return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); } + if ( + externalElementIdPrefix !== undefined && + (typeof externalElementIdPrefix !== 'string' || !validSlotIdentity(externalElementIdPrefix)) + ) { + return Object.freeze({ ok: false, reason: 'gpt_request_failed' }); + } + const elementIdPrefix = externalElementIdPrefix as string | undefined; const definition = externalDefinition === undefined ? undefined @@ -2044,7 +2106,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const previousOwnership = existing.ownership; const previousDefinition = existing.definition; const previousDomElement = existing.domElement; + const previousElementIdPrefix = existing.elementIdPrefix; const previousPlacementKeys = existing.placementKeys; + const previousPublisherElementIds = existing.publisherElementIds; try { if (!wasStrong) addSetValue(physicalSlots, existing); if (!setHasValue(physicalSlots, existing)) throw new Error('physical publication failed'); @@ -2053,7 +2117,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.ownership = ownership; existing.definition = definition; existing.domElement = domElement; + existing.elementIdPrefix = elementIdPrefix; existing.placementKeys = bindingPlacementKeys; + existing.publisherElementIds = + ownership === 'publisher' && definition + ? Object.freeze([definition.elementId]) + : Object.freeze([]); record.physical = existing; if (ownership === 'publisher') cancelReconciliation(record); return Object.freeze({ ok: true }); @@ -2063,7 +2132,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.ownership = previousOwnership; existing.definition = previousDefinition; existing.domElement = previousDomElement; + existing.elementIdPrefix = previousElementIdPrefix; existing.placementKeys = previousPlacementKeys; + existing.publisherElementIds = previousPublisherElementIds; if (!wasStrong) deleteSetValue(physicalSlots, existing); return Object.freeze({ ok: false, reason: 'stale_owner' }); } @@ -2076,16 +2147,23 @@ export function createSlotService(options: SlotServiceOptions): SlotService { artifactRetirementAttempted: false, definition, domElement, + elementIdPrefix, destroyAttempted: false, lastResponseIdentifier: undefined, ownership, placementKeys: bindingPlacementKeys, publisherIntentCount: 0, + publisherElementIds: + ownership === 'publisher' && definition + ? Object.freeze([definition.elementId]) + : Object.freeze([]), quarantineReason: undefined, record, saturationOwner: false, slot: slotObject, state: 'live', + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, }; try { setWeakMapValue(physicalByObject, slotObject, physical); @@ -2471,6 +2549,212 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return Object.freeze(handles); }; + const recordPublisherDestruction = (slot: object): boolean => { + const physical = weakMapValue(physicalByObject, slot); + if (!physical) return false; + const record = physical.record; + if (record) cancelReconciliation(record); + const cycleIntent = physical.activeCycle?.intent; + if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); + if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); + if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); + if (record) retireCommittedArtifact(record, physical); + if (record?.physical === physical) record.physical = undefined; + physical.record = undefined; + physical.activeCycle = undefined; + physical.publisherIntentCount = 0; + physical.publisherElementIds = Object.freeze([]); + physical.suppressPublisherDisplay = false; + physical.suppressPublisherRefresh = false; + physical.state = 'retired'; + releasePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + if (weakMapValue(physicalByObject, slot) === physical) { + deleteWeakMapValue(physicalByObject, slot); + } + return true; + }; + + const recordPublisherIntent = (slot: object): boolean => { + const physical = weakMapValue(physicalByObject, slot); + if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; + if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { + physical.state = 'quarantined'; + physical.quarantineReason = 'request'; + quarantinePhysicalPlacement(physical); + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + return false; + } + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + physical.publisherIntentCount += 1; + if (physical.activeCycle?.kind === 'trusted_server') { + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + physical.state = 'quarantined'; + physical.quarantineReason = 'completion'; + } + return true; + }; + + const publisherPhysicalForTarget = (target: unknown): PhysicalSlot | undefined => { + if ((typeof target === 'object' && target !== null) || typeof target === 'function') { + const exact = weakMapValue(physicalByObject, target as object); + return exact?.ownership === 'publisher' && exact.state === 'live' ? exact : undefined; + } + if (typeof target !== 'string') return undefined; + let match: PhysicalSlot | undefined; + for (const candidate of setValueSnapshot(physicalSlots)) { + if (candidate.ownership !== 'publisher' || candidate.state !== 'live') continue; + let matches = false; + for (let index = 0; index < candidate.publisherElementIds.length; index += 1) { + if (candidate.publisherElementIds[index] === target) { + matches = true; + break; + } + } + if (!matches) continue; + if (match && match !== candidate) return undefined; + match = candidate; + } + return match; + }; + + const claimPublisherGptSlot = ( + call: GoogletagPublisherDefineSlotCall + ): Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }> => { + let elementId: unknown; + let adUnitPath: unknown; + let sizes: unknown; + let initialLoadDisabled: unknown; + try { + elementId = call.elementId; + adUnitPath = call.adUnitPath; + sizes = call.sizes; + initialLoadDisabled = call.initialLoadDisabled; + } catch { + return Object.freeze({ action: 'forward' }); + } + if (typeof elementId !== 'string' || !validSlotIdentity(elementId)) { + return Object.freeze({ action: 'forward' }); + } + const exact: PhysicalSlot[] = []; + const hydration: PhysicalSlot[] = []; + for (const physical of setValueSnapshot(physicalSlots)) { + const record = physical.record; + const definition = physical.definition; + if ( + physical.ownership !== 'trusted_server' || + physical.state !== 'live' || + !record || + !record.state.owner.isCurrent() || + !definition + ) { + continue; + } + if (definition.elementId === elementId) { + exact[exact.length] = physical; + continue; + } + if ( + physical.elementIdPrefix && + elementId.startsWith(physical.elementIdPrefix) && + !reconciliationElementConnected(physical.domElement) && + adUnitPath === definition.adUnitPath && + replacementSizesEqual(sizes, definition.sizes) + ) { + hydration[hydration.length] = physical; + } + } + const matches = exact.length > 0 ? exact : hydration; + if (matches.length !== 1) return Object.freeze({ action: 'forward' }); + const physical = matches[0]; + const record = physical?.record; + if (!physical || !record || !record.state.owner.isCurrent()) { + return Object.freeze({ action: 'forward' }); + } + const definitionElementId = physical.definition?.elementId; + const aliases = + definitionElementId === undefined || definitionElementId === elementId + ? Object.freeze([elementId]) + : Object.freeze([definitionElementId, elementId]); + cancelReconciliation(record); + if ( + physical.record !== record || + record.physical !== physical || + physical.state !== 'live' || + !record.state.owner.isCurrent() + ) { + return Object.freeze({ action: 'forward' }); + } + physical.ownership = 'publisher'; + physical.publisherElementIds = aliases; + physical.suppressPublisherDisplay = true; + physical.suppressPublisherRefresh = initialLoadDisabled === true; + return Object.freeze({ action: 'handoff', slot: physical.slot }); + }; + + const preparePublisherDisplay = ( + call: GoogletagPublisherDisplayCall + ): Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }> => { + let target: unknown; + let initialLoadDisabled: unknown; + try { + target = call.target; + initialLoadDisabled = call.initialLoadDisabled; + } catch { + return Object.freeze({ action: 'forward' }); + } + const physical = publisherPhysicalForTarget(target); + if (!physical) return Object.freeze({ action: 'forward' }); + if (physical.suppressPublisherDisplay) { + physical.suppressPublisherDisplay = false; + return Object.freeze({ action: 'suppress' }); + } + if (initialLoadDisabled !== true) recordPublisherIntent(physical.slot); + return Object.freeze({ action: 'forward' }); + }; + + const preparePublisherRefresh = ( + call: GoogletagPublisherRefreshCall + ): + | Readonly<{ action: 'forward' }> + | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'suppress' }> => { + let slots: readonly object[]; + try { + slots = call.slots; + } catch { + return Object.freeze({ action: 'forward' }); + } + if (!Array.isArray(slots)) return Object.freeze({ action: 'forward' }); + let suppressed = false; + const forwarded: object[] = []; + for (let index = 0; index < slots.length; index += 1) { + const slot = slots[index]; + if (!slot) continue; + const physical = weakMapValue(physicalByObject, slot); + if (physical?.ownership === 'publisher' && physical.suppressPublisherRefresh) { + physical.suppressPublisherRefresh = false; + suppressed = true; + continue; + } + forwarded[forwarded.length] = slot; + if (physical?.ownership === 'publisher') recordPublisherIntent(slot); + } + if (!suppressed) return Object.freeze({ action: 'forward' }); + if (forwarded.length === 0) return Object.freeze({ action: 'suppress' }); + return Object.freeze({ action: 'replace', slots: Object.freeze(forwarded) }); + }; + const service: SlotService = Object.freeze({ activate: (): GoogletagOperation => { if (activation) return activation; @@ -2589,6 +2873,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }, }); }, + claimPublisherGptSlot, + preparePublisherDisplay, + preparePublisherRefresh, projectionRegistry: (owner: NavigationSession): ProjectionSlotRegistry => Object.freeze({ prepareProjectionSlots: ( @@ -2605,57 +2892,8 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return service.prepareProjectionSlots(owner, slots); }, }), - recordPublisherDestruction: (slot: object): boolean => { - const physical = weakMapValue(physicalByObject, slot); - if (!physical) return false; - const record = physical.record; - if (record) cancelReconciliation(record); - const cycleIntent = physical.activeCycle?.intent; - if (cycleIntent && !cycleIntent.terminal) settle(cycleIntent, failed('gpt_request_failed')); - if (record?.activeIntent) settle(record.activeIntent, failed('gpt_request_failed')); - if (record?.queuedIntent) settle(record.queuedIntent, failed('gpt_request_failed')); - if (record) retireCommittedArtifact(record, physical); - if (record?.physical === physical) record.physical = undefined; - physical.record = undefined; - physical.activeCycle = undefined; - physical.publisherIntentCount = 0; - physical.state = 'retired'; - releasePhysicalPlacement(physical); - deleteSetValue(physicalSlots, physical); - if (weakMapValue(physicalByObject, slot) === physical) { - deleteWeakMapValue(physicalByObject, slot); - } - return true; - }, - recordPublisherIntent: (slot: object): boolean => { - const physical = weakMapValue(physicalByObject, slot); - if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; - if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { - physical.state = 'quarantined'; - physical.quarantineReason = 'request'; - quarantinePhysicalPlacement(physical); - if (physical.record?.activeIntent) { - settle(physical.record.activeIntent, failed('cycle_unattributable')); - } - if (physical.record?.queuedIntent) { - settle(physical.record.queuedIntent, failed('cycle_unattributable')); - } - return false; - } - if (physical.record?.activeIntent) { - settle(physical.record.activeIntent, failed('cycle_unattributable')); - } - if (physical.record?.queuedIntent) { - settle(physical.record.queuedIntent, failed('cycle_unattributable')); - } - physical.publisherIntentCount += 1; - if (physical.activeCycle?.kind === 'trusted_server') { - physical.activeCycle = { intent: undefined, kind: 'publisher' }; - physical.state = 'quarantined'; - physical.quarantineReason = 'completion'; - } - return true; - }, + recordPublisherDestruction, + recordPublisherIntent, registeredSlotIdsForTest: (): readonly string[] => { const records = mapValueSnapshot(registeredSlots); records.sort((left, right) => left.view.ordinal - right.view.ordinal); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index dce6d1c01..82cdd3edd 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1942,11 +1942,80 @@ describe('browser googletag adapter readiness', () => { expect(ready.pubads.refresh).toBe(nativeRefresh); }); + it('installs the publisher observer when an accepted command-queue stub becomes ready', () => { + const commands: Array<() => void> = []; + const pending = { + cmd: { + push: vi.fn((callback: () => void) => { + commands.push(callback); + return commands.length; + }), + }, + }; + const target = { googletag: pending as object }; + const adapter = createBrowserGoogletagAdapter(target); + const handoff = {}; + const release = adapter.observePublisherCalls({ + defineSlot: () => Object.freeze({ action: 'handoff', slot: handoff }), + }); + const ready = createReadyGoogletag(); + const nativeDefineSlot = vi.fn((_path: string, _sizes: unknown, _elementId: string) => ({})); + Object.assign(pending, { + apiReady: true, + defineSlot: nativeDefineSlot, + destroySlots: ready.googletag.destroySlots, + display: ready.googletag.display, + getConfig: ready.googletag.getConfig, + pubads: ready.googletag.pubads, + pubadsReady: true, + setConfig: ready.googletag.setConfig, + }); + + expect(commands).toHaveLength(1); + commands[0]?.(); + const defineSlot = (pending as typeof pending & { defineSlot: typeof nativeDefineSlot }) + .defineSlot; + expect(defineSlot).not.toBe(nativeDefineSlot); + expect(defineSlot('/publisher', [300, 250], 'slot')).toBe(handoff); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + + release(); + expect((pending as typeof pending & { defineSlot: typeof nativeDefineSlot }).defineSlot).toBe( + nativeDefineSlot + ); + }); + + it('does not classify facade-driven GPT calls as publisher calls', async () => { + const ready = createReadyGoogletag(); + const nativeRefresh = ready.pubads.refresh; + const observer = { + display: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect( + adapter.run((gpt) => { + gpt.display('trusted-slot'); + gpt.refresh([], { changeCorrelator: false }); + }).result + ).resolves.toBeUndefined(); + + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).not.toHaveBeenCalled(); + expect(ready.display).toHaveBeenCalledExactlyOnceWith('trusted-slot'); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([], { + changeCorrelator: false, + }); + }); + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { const ready = createReadyGoogletag({ initialLoadDisabled: true }); const handoffSlot = Object.freeze({ id: 'handoff' }); const ordinarySlot = Object.freeze({ id: 'ordinary' }); const refreshOptions = Object.freeze({ changeCorrelator: true, publisher: 'kept' }); + const publisherError = new Error('publisher display failed'); const defineReceiver = Object.freeze({ receiver: 'define' }); const refreshReceiver = Object.freeze({ receiver: 'refresh' }); const order: string[] = []; @@ -1956,6 +2025,7 @@ describe('browser googletag adapter readiness', () => { }); const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { order.push('native:display'); + if (arguments_[0] === 'throw') throw publisherError; return Object.freeze({ arguments_, receiver: this }); }); const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { @@ -2030,6 +2100,9 @@ describe('browser googletag adapter readiness', () => { arguments_: ['handoff-id', 'publisher-extra'], receiver: defineReceiver, }); + expect(() => Reflect.apply(display, defineReceiver, ['throw', 'publisher-extra'])).toThrow( + publisherError + ); const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; expect(Reflect.apply(refresh, refreshReceiver, [undefined, refreshOptions])).toEqual({ @@ -2047,6 +2120,7 @@ describe('browser googletag adapter readiness', () => { 'native:define', 'observer:display', 'native:display', + 'native:display', 'observer:refresh', 'native:refresh', 'native:destroy', diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 3df0f52fa..7ab2c9811 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + createBrowserGoogletagAdapter, createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, @@ -673,6 +674,124 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + it('hands late publisher GPT calls through the adapter into runtime-owned slot state', async () => { + const releaseId = 'a'.repeat(64); + const slot = Object.freeze({ id: 'trusted-slot' }); + const unrelated = Object.freeze({ id: 'publisher-slot' }); + const refresh = vi.fn((_slots?: readonly object[], _options?: unknown) => undefined); + const display = vi.fn((_target: unknown) => undefined); + const destroySlots = vi.fn((_slots?: readonly object[]) => true); + const listeners = new Map void>>(); + const pubads = { + addEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + const registered = listeners.get(type) ?? new Set(); + registered.add(listener); + listeners.set(type, registered); + }), + disableInitialLoad: vi.fn(), + getSlots: vi.fn(() => [slot, unrelated]), + refresh, + removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { + listeners.get(type)?.delete(listener); + }), + }; + const nativeDefineSlot = vi.fn((_path: string, _sizes: unknown, _elementId: string) => + Object.freeze({ id: 'duplicate' }) + ); + const googletag = { + apiReady: true, + pubadsReady: true, + cmd: { push: (command: () => void) => (command(), 0) }, + defineSlot: nativeDefineSlot, + destroySlots, + display, + getConfig: vi.fn(() => ({ disableInitialLoad: true })), + pubads: () => pubads, + setConfig: vi.fn(), + }; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot', outcome: 'no_bid' }], + }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: createBrowserGoogletagAdapter({ googletag }), + messaging: fakeMessagingAdapter(() => vi.fn()), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + const slots = composition.slotServiceForTest(); + if (!navigation || !slots) throw new Error('Expected active GPT composition'); + expect( + slots.adoptGptSlot(navigation.generation, 'slot', { + definition: { + adUnitPath: '/trusted/path', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250]]), + }, + elementIdPrefix: 'slot-', + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + + expect(googletag.defineSlot('/publisher/mismatch', [728, 90], 'slot-div')).toBe(slot); + expect(nativeDefineSlot).not.toHaveBeenCalled(); + expect(googletag.display('slot-div')).toBeUndefined(); + expect(display).not.toHaveBeenCalled(); + const options = Object.freeze({ changeCorrelator: true, publisher: 'preserved' }); + expect(pubads.refresh(undefined, options)).toBeUndefined(); + expect(refresh).toHaveBeenCalledExactlyOnceWith([unrelated], options); + pubads.refresh([slot], options); + expect(refresh).toHaveBeenLastCalledWith([slot], options); + const request = slots.request({ + intentId: 'publisher-owned', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + expect(googletag.destroySlots([slot])).toBe(true); + expect(slots.isBoundGptSlot(navigation.generation, 'slot', slot)).toBe(false); + } finally { + composition.runtime.dispose(); + resetGuardState(); + } + expect(destroySlots).toHaveBeenCalledTimes(1); + }); + it('constructs one session lazily from accepted boot and keeps it across SPA replacement', async () => { const projection = { version: 1, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 878a3a4ac..4f9e6b0ce 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -141,6 +141,11 @@ describe('transactional GPT integration module', () => { order.push('start'); expect(received).toBe(config); }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('gpt:activate'); + return release; + }); let finishPreparation: (() => void) | undefined; const preparationGate = new Promise((resolve) => { finishPreparation = resolve; @@ -153,7 +158,7 @@ describe('transactional GPT integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ gpt: Object.freeze({ activate, start }) }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); @@ -179,7 +184,16 @@ describe('transactional GPT integration module', () => { expect(result).toMatchObject({ state: 'kernel' }); expect(isGuardInstalled()).toBe(true); expect(document.write).not.toBe(originalDocumentWrite); - expect(order).toEqual(['gate:prepare', 'core', 'gate:activate', 'publish', 'start', 'drain']); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'gpt:activate', + 'gate:activate', + 'publish', + 'start', + 'drain', + ]); + expect(activate).toHaveBeenCalledTimes(1); expect(start).toHaveBeenCalledExactlyOnceWith(config); if (result.state === 'kernel') { @@ -188,6 +202,7 @@ describe('transactional GPT integration module', () => { } expect(isGuardInstalled()).toBe(false); expect(document.write).toBe(originalDocumentWrite); + expect(release).toHaveBeenCalledTimes(1); }); it('unwinds the GPT guard before fallback when a later activation fails', async () => { @@ -200,7 +215,9 @@ describe('transactional GPT integration module', () => { now: () => 0, getBindings: () => ({ config: Object.freeze({}), - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); @@ -222,6 +239,37 @@ describe('transactional GPT integration module', () => { expect(start).not.toHaveBeenCalled(); }); + it('never installs the guard or starts when reversible GPT activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['gpt']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + gpt: Object.freeze({ + activate: () => { + expect(isGuardInstalled()).toBe(false); + throw new Error('fictional observer activation failure'); + }, + start, + }), + }), + }), + }); + registry.register(createGptIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(isGuardInstalled()).toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + it('fails preparation without effects when the composition omits the GPT boundary', async () => { const registry = createIntegrationRegistry({ manifest: manifest(['gpt']), @@ -263,7 +311,9 @@ describe('transactional GPT integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); @@ -290,7 +340,9 @@ describe('transactional GPT integration module', () => { onRuntimeFailure: (failure) => runtimeFailures.push(failure), getBindings: () => ({ config: Object.freeze({}), - interfaces: Object.freeze({ gpt: Object.freeze({ start }) }), + interfaces: Object.freeze({ + gpt: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createGptIntegrationRegistration(RELEASE_ID)); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts new file mode 100644 index 000000000..9de10d2f9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagPublisherCallObserver, +} from '../../../src/adapters/googletag'; +import { createGptStartup } from '../../../src/integrations/gpt/startup'; +import type { SlotService } from '../../../src/services/slots'; + +describe('GPT startup bridge', () => { + it('installs one reversible typed observer and delegates all handoff state to slots', () => { + let observer: GoogletagPublisherCallObserver | undefined; + const release = vi.fn(); + const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { + observer = candidate; + return release; + }); + const adapter = Object.freeze({ observePublisherCalls }) as unknown as GoogletagAdapter; + const slot = {}; + const slots = Object.freeze({ + claimPublisherGptSlot: vi.fn(() => Object.freeze({ action: 'handoff' as const, slot })), + preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + preparePublisherRefresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), + recordPublisherDestruction: vi.fn(() => true), + }) satisfies Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + >; + const start = vi.fn(); + const startup = createGptStartup({ googletag: adapter, slots: () => slots, start }); + + expect(startup.activate()).toBe(release); + expect(observePublisherCalls).toHaveBeenCalledTimes(1); + expect( + observer?.defineSlot?.({ + adUnitPath: '/publisher', + elementId: 'slot', + initialLoadDisabled: true, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + expect(observer?.display?.({ initialLoadDisabled: true, target: 'slot' })).toEqual({ + action: 'suppress', + }); + expect( + observer?.refresh?.({ requestedSlots: undefined, slots: Object.freeze([slot]) }) + ).toEqual({ action: 'suppress' }); + observer?.destroySlots?.({ slots: Object.freeze([slot, {}]) }); + expect(slots.recordPublisherDestruction).toHaveBeenCalledTimes(2); + + const config = Object.freeze({ disableInitialLoad: true }); + startup.start(config); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + }); +}); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 1f260c66d..642907875 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -484,6 +484,136 @@ describe('slot registry', () => { expect(service.isBoundGptSlot(navigation.generation, 'trusted', trustedSlot)).toBe(false); }); + it('hands an exact late publisher definition the TS slot and consumes only duplicate requests', async () => { + const gpt = createGptHarness({ initialLoadDisabled: true }); + const service = createSlotService({ googletag: gpt.adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + const slot = bindTrustedSlot(service, navigation); + + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/publisher/mismatch', + elementId: 'slot-div', + initialLoadDisabled: true, + sizes: Object.freeze([[728, 90]]), + }) + ).toEqual({ action: 'handoff', slot }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) + ).toEqual({ action: 'suppress' }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) + ).toEqual({ action: 'forward' }); + + const unrelated = {}; + expect( + service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([slot, unrelated]), + }) + ).toEqual({ action: 'replace', slots: [unrelated] }); + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'forward' }); + + const request = service.request({ + intentId: 'after-publisher-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + + runtime.dispose(); + expect(gpt.destroySlots).not.toHaveBeenCalled(); + }); + + it('hydrates only one disconnected TS fallback with the configured prefix, path, and sizes', () => { + const dom = createReconciliationBoundary(); + const firstElement = {}; + const secondElement = {}; + dom.put('slot-first', firstElement); + dom.put('slot-second', secondElement); + const service = createSlotService({ + googletag: createGptHarness().adapter, + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + expect( + service.register(navigation, [serverRegistration('first'), serverRegistration('second')]) + ).toMatchObject({ ok: true }); + const first = {}; + const second = {}; + for (const [id, slot] of [ + ['first', first], + ['second', second], + ] as const) { + expect( + service.adoptGptSlot(navigation.generation, id, { + definition: { + adUnitPath: '/network/hydrated', + elementId: `slot-${id}`, + sizes: Object.freeze([[300, 250]]), + }, + elementIdPrefix: 'slot-', + ownership: 'trusted_server', + slot, + }) + ).toEqual({ ok: true }); + dom.disconnect(`slot-${id}`); + } + + const hydration = Object.freeze({ + adUnitPath: '/network/hydrated', + elementId: 'slot-hydrated', + initialLoadDisabled: false, + sizes: Object.freeze([300, 250]), + }); + expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'forward' }); + expect(service.recordPublisherDestruction(second)).toBe(true); + expect( + service.claimPublisherGptSlot({ ...hydration, adUnitPath: '/network/mismatch' }) + ).toEqual({ action: 'forward' }); + expect(service.claimPublisherGptSlot({ ...hydration, sizes: [728, 90] })).toEqual({ + action: 'forward', + }); + expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'handoff', slot: first }); + }); + + it('suppresses the exact first explicit refresh after a disabled-load handoff', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: true, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'suppress' }); + expect( + service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }) + ).toEqual({ action: 'forward' }); + }); + it('uses captured Set validation intrinsics on a hostile page', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const navigation = createNavigation(); From acbc22169b1ad319943d39475572c4b6f3989036 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:08:51 -0700 Subject: [PATCH 344/494] Verify Prebid admission against the real artifact --- .../lib/build-prebid-external.mjs | 2 +- .../lib/src/adapters/prebid.ts | 76 ++++++++++++---- .../lib/test/adapters/prebid.test.ts | 54 +++++++++-- .../test/prebid-artifact-integration.test.mjs | 90 +++++++++++++++++++ 4 files changed, 197 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 1aaebb606..7f972da5a 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -312,7 +312,7 @@ function renderExternalWrapper(bundleCode, stamp) { `var __tsExistingWindow=window;var __tsExisting=__tsExistingWindow.pbjs;var __tsExistingDescriptor;try{__tsExistingDescriptor=__tsExisting&&Object.getOwnPropertyDescriptor(__tsExisting,"${ARTIFACT_PROPERTY}");}catch(_){__tsExistingDescriptor=undefined;}`, 'if(__tsExistingDescriptor&&Object.prototype.hasOwnProperty.call(__tsExistingDescriptor,"value")&&__tsExistingDescriptor.enumerable===false&&__tsExistingDescriptor.writable===false&&__tsExistingDescriptor.configurable===false&&__tsValidStamp(__tsExistingDescriptor.value)){if(__tsEqual(__tsExistingDescriptor.value,__tsStamp))return;__tsWarn();return;}', bundleCode, - `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;var __tsInherited=false;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");__tsInherited=!__tsAfter&&Reflect.has(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;__tsInherited=true;}if(!__tsAfter&&!__tsInherited){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(__tsInherited||!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, + `var __tsPbjs=window.pbjs;var __tsRequired=["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids"];var __tsReady=!!__tsPbjs;for(var __tsIndex=0;__tsReady&&__tsIndex<__tsRequired.length;__tsIndex+=1)__tsReady=typeof __tsPbjs[__tsRequired[__tsIndex]]==="function";if(__tsReady){var __tsAfter;var __tsInherited=false;try{__tsAfter=Object.getOwnPropertyDescriptor(__tsPbjs,"${ARTIFACT_PROPERTY}");__tsInherited=!__tsAfter&&Reflect.has(__tsPbjs,"${ARTIFACT_PROPERTY}");}catch(_){__tsAfter=undefined;__tsInherited=true;}if(!__tsAfter&&!__tsInherited){try{Object.defineProperty(__tsPbjs,"${ARTIFACT_PROPERTY}",{value:__tsStamp,enumerable:false,writable:false,configurable:false});}catch(_){__tsWarn();}}else if(__tsInherited||!Object.prototype.hasOwnProperty.call(__tsAfter,"value")||!__tsEqual(__tsAfter.value,__tsStamp)){__tsWarn();}}`, '})();', '', ].join('\n'); diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index bee06c111..123022701 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -207,7 +207,7 @@ interface PendingOperation { interface ActiveTrustedServerAdmission { readonly addBidResponse: (...arguments_: unknown[]) => unknown; readonly binding: PresentPrebid; - readonly requests: readonly PrebidTrustedServerBidRequestV1[]; + readonly requests: readonly CapturedTrustedServerBidRequest[]; readonly admittedIds: Set; readonly admittedRequests: Set; readonly attemptedRequests: Set; @@ -216,6 +216,11 @@ interface ActiveTrustedServerAdmission { complete(): void; } +interface CapturedTrustedServerBidRequest extends PrebidTrustedServerBidRequestV1 { + readonly adUnitId: string; + readonly transactionId: string; +} + const encoder = new TextEncoder(); function validUnicodeScalars(value: string): boolean { @@ -703,6 +708,7 @@ export function createBrowserPrebidAdapter( | Readonly<{ auctionId: string; bids: readonly PrebidTrustedServerBidRequestV1[]; + requests: readonly CapturedTrustedServerBidRequest[]; }> | undefined => { try { @@ -723,29 +729,58 @@ export function createBrowserPrebidAdapter( ) { return undefined; } - const requests: PrebidTrustedServerBidRequestV1[] = []; + const bids: PrebidTrustedServerBidRequestV1[] = []; + const requests: CapturedTrustedServerBidRequest[] = []; const identities = new Set(); for (const rawBid of bidsDescriptor.value as unknown[]) { if (typeof rawBid !== 'object' || rawBid === null || Array.isArray(rawBid)) return undefined; const adUnitCode = safeOwnDescriptor(rawBid, 'adUnitCode'); + const adUnitId = safeOwnDescriptor(rawBid, 'adUnitId'); + const bidAuctionId = safeOwnDescriptor(rawBid, 'auctionId'); const requestId = safeOwnDescriptor(rawBid, 'bidId'); + const source = safeOwnDescriptor(rawBid, 'src'); + const transactionId = safeOwnDescriptor(rawBid, 'transactionId'); if ( !adUnitCode || !Object.prototype.hasOwnProperty.call(adUnitCode, 'value') || !validString(adUnitCode.value, 256) || + !adUnitId || + !Object.prototype.hasOwnProperty.call(adUnitId, 'value') || + !validString(adUnitId.value, 128) || + !bidAuctionId || + !Object.prototype.hasOwnProperty.call(bidAuctionId, 'value') || + bidAuctionId.value !== auctionId.value || !requestId || !Object.prototype.hasOwnProperty.call(requestId, 'value') || - !validString(requestId.value, 128) + !validString(requestId.value, 128) || + !source || + !Object.prototype.hasOwnProperty.call(source, 'value') || + source.value !== 'client' || + !transactionId || + !Object.prototype.hasOwnProperty.call(transactionId, 'value') || + !validString(transactionId.value, 128) ) { return undefined; } const identity = `${adUnitCode.value}\u0000${requestId.value}`; if (identities.has(identity)) return undefined; identities.add(identity); - requests.push(Object.freeze({ adUnitCode: adUnitCode.value, requestId: requestId.value })); + bids.push(Object.freeze({ adUnitCode: adUnitCode.value, requestId: requestId.value })); + requests.push( + Object.freeze({ + adUnitCode: adUnitCode.value, + adUnitId: adUnitId.value, + requestId: requestId.value, + transactionId: transactionId.value, + }) + ); } - return Object.freeze({ auctionId: auctionId.value, bids: Object.freeze(requests) }); + return Object.freeze({ + auctionId: auctionId.value, + bids: Object.freeze(bids), + requests: Object.freeze(requests), + }); } catch { return undefined; } @@ -753,23 +788,25 @@ export function createBrowserPrebidAdapter( const responseCount = ( binding: PresentPrebid, + auctionId: string, adUnitCode: string, adId: string, requestId: string, isCurrent: () => boolean ): number => { const response = callBound(binding, 'getBidResponsesForAdUnitCode', [adUnitCode], isCurrent); - if (typeof response !== 'object' || response === null || Array.isArray(response)) { + if (!Array.isArray(response)) { throw new PrebidAdapterError('external_artifact_incompatible'); } const bids = safeMember(response, 'bids'); - if (!Array.isArray(bids)) throw new PrebidAdapterError('external_artifact_incompatible'); + if (bids !== response) throw new PrebidAdapterError('external_artifact_incompatible'); let matches = 0; - for (const bid of bids) { + for (const bid of response) { if (typeof bid !== 'object' || bid === null) { throw new PrebidAdapterError('external_artifact_incompatible'); } if ( + safeMember(bid, 'auctionId') === auctionId && safeMember(bid, 'adId') === adId && safeMember(bid, 'requestId') === requestId && safeMember(bid, 'adUnitCode') === adUnitCode @@ -792,12 +829,12 @@ export function createBrowserPrebidAdapter( throw new PrebidAdapterError('external_artifact_incompatible'); } const requestIdentity = `${prepared.adUnitCode}\u0000${prepared.bid.requestId}`; - if ( - !context.requests.some( - (request) => - request.adUnitCode === prepared.adUnitCode && request.requestId === prepared.bid.requestId - ) - ) { + const request = context.requests.find( + (candidateRequest) => + candidateRequest.adUnitCode === prepared.adUnitCode && + candidateRequest.requestId === prepared.bid.requestId + ); + if (!request) { return 'not_admitted'; } if ( @@ -811,6 +848,7 @@ export function createBrowserPrebidAdapter( const isCurrent = (): boolean => !disposed && sameBinding(context.binding); const before = responseCount( context.binding, + prepared.auctionId, prepared.adUnitCode, prepared.bid.adId, prepared.bid.requestId, @@ -827,6 +865,7 @@ export function createBrowserPrebidAdapter( if ( typeof event === 'object' && event !== null && + safeMember(event, 'auctionId') === prepared.auctionId && safeMember(event, 'adId') === prepared.bid.adId && safeMember(event, 'requestId') === prepared.bid.requestId && safeMember(event, 'adUnitCode') === prepared.adUnitCode @@ -839,10 +878,16 @@ export function createBrowserPrebidAdapter( try { const mutableBid = { ...prepared.bid, + adUnitId: request.adUnitId, + auctionId: prepared.auctionId, + getSize: (): string => `${prepared.bid.width}x${prepared.bid.height}`, + mediaType: 'banner', meta: { ...prepared.bid.meta, advertiserDomains: [...prepared.bid.meta.advertiserDomains], }, + source: 'client', + transactionId: request.transactionId, }; Reflect.apply(context.addBidResponse, undefined, [prepared.adUnitCode, mutableBid]); } catch (error) { @@ -858,6 +903,7 @@ export function createBrowserPrebidAdapter( try { after = responseCount( context.binding, + prepared.auctionId, prepared.adUnitCode, prepared.bid.adId, prepared.bid.requestId, @@ -970,7 +1016,7 @@ export function createBrowserPrebidAdapter( const context: ActiveTrustedServerAdmission = { addBidResponse: rawAddBidResponse as (...arguments_: unknown[]) => unknown, binding, - requests: request.bids, + requests: request.requests, admittedIds: new Set(), admittedRequests: new Set(), attemptedRequests: new Set(), diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 00cfc2982..6cbb7b1d3 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -4,6 +4,12 @@ import { createBrowserPrebidAdapter, type PrebidEventFacade } from '../../src/ad type Command = () => void; +function wrapBids(bids: object[] = []): object[] & { bids: object[] } { + const response = [...bids] as object[] & { bids: object[] }; + response.bids = response; + return response; +} + function recursivelyFreeze(value: T): T { if (value && typeof value === 'object') { for (const child of Object.values(value)) recursivelyFreeze(child); @@ -41,7 +47,7 @@ function createReadyPrebid( const listeners = new Map void>>(); const pbjs = { addAdUnits: vi.fn(), - getBidResponsesForAdUnitCode: vi.fn<() => { bids: object[] }>(() => ({ bids: [] })), + getBidResponsesForAdUnitCode: vi.fn<() => object[] & { bids: object[] }>(() => wrapBids()), getHighestCpmBids: vi.fn<() => object[]>(() => []), offEvent: vi.fn((type: string, listener: (event: unknown) => void) => { listeners.get(type)?.delete(listener); @@ -1552,9 +1558,9 @@ describe('version-pinned Trusted Server bid admission', () => { function admissionFixture() { const ready = createReadyPrebid(); const stored: object[] = []; - ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation((adUnitCode?: string) => ({ - bids: stored.filter((bid) => (bid as { adUnitCode?: unknown }).adUnitCode === adUnitCode), - })); + ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation((adUnitCode?: string) => + wrapBids(stored.filter((bid) => (bid as { adUnitCode?: unknown }).adUnitCode === adUnitCode)) + ); const target: { pbjs: unknown } = { pbjs: ready.pbjs }; const adapter = createBrowserPrebidAdapter(target); const auctions: unknown[] = []; @@ -1587,7 +1593,16 @@ describe('version-pinned Trusted Server bid admission', () => { bidder?.callBids( { auctionId: 'auction-one', - bids: [{ adUnitCode: 'slot-one', bidId: 'request-one' }], + bids: [ + { + adUnitCode: 'slot-one', + adUnitId: 'ad-unit-one', + auctionId: 'auction-one', + bidId: 'request-one', + src: 'client', + transactionId: 'transaction-one', + }, + ], }, admit, done @@ -1630,8 +1645,16 @@ describe('version-pinned Trusted Server bid admission', () => { expect(fixture.boundary.admitTrustedBid(prepared)).toBe('admitted'); expect(fixture.admit).toHaveBeenCalledTimes(1); const admitted = fixture.admit.mock.calls[0]?.[1]; - expect(admitted).toEqual(prepared.bid); + expect(admitted).toMatchObject(prepared.bid); expect(admitted).not.toBe(prepared.bid); + expect(admitted).toMatchObject({ + adUnitId: 'ad-unit-one', + auctionId: 'auction-one', + mediaType: 'banner', + source: 'client', + transactionId: 'transaction-one', + }); + expect(Reflect.apply(admitted?.['getSize'] as () => string, admitted, [])).toBe('300x250'); expect(admitted?.['meta']).not.toBe(prepared.bid.meta); expect((admitted?.['meta'] as { advertiserDomains?: unknown })?.advertiserDomains).not.toBe( prepared.bid.meta.advertiserDomains @@ -1652,6 +1675,19 @@ describe('version-pinned Trusted Server bid admission', () => { expect(fixture.stored).toEqual([]); }); + it('rejects a response query that does not use the pinned self-wrapped array shape', async () => { + const fixture = admissionFixture(); + await fixture.operation.result; + fixture.ready.pbjs.getBidResponsesForAdUnitCode.mockImplementation( + () => ({ bids: [] }) as never + ); + + expect(() => fixture.boundary.admitTrustedBid(preparedBid())).toThrowError( + expect.objectContaining({ code: 'external_artifact_incompatible' }) + ); + expect(fixture.admit).not.toHaveBeenCalled(); + }); + it('makes a request terminal after not_admitted instead of retrying publication', async () => { const fixture = admissionFixture(); await fixture.operation.result; @@ -1668,19 +1704,19 @@ describe('version-pinned Trusted Server bid admission', () => { expect(fixture.stored).toEqual([]); }); - it('matches response state and events by exact request and ad-unit identity', async () => { + it('matches response state and events by exact auction, request, and ad-unit identity', async () => { const fixture = admissionFixture(); await fixture.operation.result; const prepared = preparedBid(); fixture.stored.push({ ...prepared.bid, - requestId: 'other-request', + auctionId: 'other-auction', adUnitCode: prepared.adUnitCode, }); fixture.admit.mockImplementation((adUnitCode, bid) => { fixture.emitBidResponse({ ...bid, - requestId: 'other-request', + auctionId: 'other-auction', adUnitCode, }); const published = { ...bid, adUnitCode }; diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 56c52349d..459d72db6 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -203,6 +203,96 @@ describe('external bundle + served shim evaluated together', () => { dom.window.close(); }); + it('admits one exact TS bid through the real 10.26.0 response callback', async () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.eval(bundleCode); + + const adapter = createBrowserPrebidAdapter(pageWindow); + let resolveAuction; + const auctionReady = new Promise((resolve) => { + resolveAuction = resolve; + }); + let resolveBidsBack; + const bidsBack = new Promise((resolve) => { + resolveBidsBack = resolve; + }); + const operation = adapter.run((prebid) => { + prebid.registerTrustedServerBidder(resolveAuction); + return prebid.requestBids({ + adUnits: [ + { + code: 'slot-one', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ bidder: 'trustedServer', params: {} }], + }, + ], + timeout: 1_000, + bidsBackHandler: resolveBidsBack, + }); + }); + await operation.result; + const auction = await auctionReady; + expect(Object.isFrozen(auction)).toBe(true); + expect(auction.bids).toHaveLength(1); + + const request = auction.bids[0]; + const reservationId = `r1_${'z'.repeat(22)}`; + const prepared = Object.freeze({ + auctionId: auction.auctionId, + adUnitCode: request.adUnitCode, + bid: Object.freeze({ + requestId: request.requestId, + adId: reservationId, + cpm: 1.25, + width: 300, + height: 250, + ad: '', + ttl: 300, + creativeId: 'creative-one', + netRevenue: true, + currency: 'USD', + bidderCode: 'trustedServer', + meta: Object.freeze({ + advertiserDomains: Object.freeze([]), + tsAuctionId: auction.auctionId, + tsBidId: 'server-bid-one', + }), + }), + }); + + const beforeAdmission = pageWindow.pbjs.getBidResponsesForAdUnitCode('slot-one'); + expect(Array.isArray(beforeAdmission)).toBe(true); + expect(Array.isArray(beforeAdmission.bids)).toBe(true); + expect(beforeAdmission.bids).toHaveLength(0); + expect(adapter.admitTrustedBid(prepared)).toBe('admitted'); + const stored = pageWindow.pbjs.getBidResponsesForAdUnitCode('slot-one').bids; + const admitted = stored.filter((bid) => bid.adId === reservationId); + expect(admitted).toHaveLength(1); + expect(admitted[0]).toMatchObject({ + adId: reservationId, + adUnitCode: 'slot-one', + auctionId: auction.auctionId, + requestId: request.requestId, + adserverTargeting: { hb_adid: reservationId }, + }); + auction.complete(); + await bidsBack; + adapter.dispose(); + dom.window.close(); + }, 60_000); + it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', From 5da081a337f25a40506bd38d1b0aee4122cd4b18 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:09:38 -0700 Subject: [PATCH 345/494] Fix Prebid contract test command --- .../plans/2026-08-04-aps-tsjs-resilience-implementation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index b7a1a1675..f83552b8a 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -2088,7 +2088,7 @@ collapse those checkpoints or carry unverified behavior between them. test/adapters/googletag.test.ts \ test/integrations/gpt/ad_init.test.ts npm --prefix crates/trusted-server-js/lib run build:prebid-external - node --test \ + npm --prefix crates/trusted-server-js/lib test -- --run \ crates/trusted-server-js/lib/test/build-prebid-external.test.mjs \ crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs ``` From 936bf5ccc0d7a240bc5a8589dd2784c8d6f0f523 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:10:10 -0700 Subject: [PATCH 346/494] Pin the Prebid response query contract --- crates/trusted-server-js/lib/test/adapters/prebid.test.ts | 1 + crates/trusted-server-js/lib/test/build-prebid-external.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 6cbb7b1d3..10295f642 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -611,6 +611,7 @@ describe('browser Prebid adapter readiness', () => { it('requires every real API method and contains hostile target and member getters', async () => { for (const method of [ 'addAdUnits', + 'getBidResponsesForAdUnitCode', 'getHighestCpmBids', 'offEvent', 'onEvent', diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index b6afbb0b2..979928a0f 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -100,6 +100,7 @@ describe('build-prebid-external metadata', () => { expect(manifest.sha256).toMatch(/^[0-9a-f]{64}$/); expect(manifest.sri).toMatch(/^sha384-/); expect(bundle).toContain('__trustedServerArtifactV1'); + expect(bundle).toContain('getBidResponsesForAdUnitCode'); expect(bundle).toContain(manifest.artifactReleaseId); expect(bundle).not.toContain(ARTIFACT_RELEASE_SENTINEL); expect(bundle).not.toContain('__tsjs_prebid_bundle'); From ec6eb5b00c4c8449cffea2999fc67c1dd9cc6dd6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:12:13 -0700 Subject: [PATCH 347/494] Activate Prebid listeners transactionally --- .../lib/src/integrations/prebid/module.ts | 24 ++++- .../test/integrations/prebid/module.test.ts | 96 +++++++++++++++++-- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index b5672d18f..6f28fc04c 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -42,6 +42,7 @@ const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; const objectIsFrozenIntrinsic = Object.isFrozen; interface PrebidIntegrationRuntime { + readonly activate: () => () => void; readonly start: (config: unknown) => void; } @@ -110,12 +111,22 @@ function readPrebidRuntime( candidate === null || arrayIsArrayIntrinsic(candidate) || !objectIsFrozenIntrinsic(candidate) || - Reflect.ownKeys(candidate).length !== 1 + Reflect.ownKeys(candidate).length !== 2 ) { return undefined; } + const activate = objectGetOwnPropertyDescriptorIntrinsic(candidate, 'activate'); const start = objectGetOwnPropertyDescriptorIntrinsic(candidate, 'start'); - if (!start || !('value' in start) || typeof start.value !== 'function') return undefined; + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } return candidate as PrebidIntegrationRuntime; } catch { return undefined; @@ -133,7 +144,14 @@ export function createPrebidIntegrationRegistration(release: string): Integratio if (!runtime) throw new TypeError('Prebid integration runtime is unavailable'); return Object.freeze({ - activate: ({ afterCommit }: IntegrationActivationContext) => { + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const release = runtime.activate(); + if (typeof release !== 'function') { + throw new TypeError('Prebid integration activation disposer is unavailable'); + } + runtimeRelease.value = release; afterCommit(() => runtime.start(config)); }, }); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index 883aec893..2ea424d0c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -48,13 +48,18 @@ function callbacks(order: string[]): IntegrationInstallCallbacks { } describe('transactional Prebid integration module', () => { - it('prepares inertly and starts the external boundary only after commit', async () => { + it('prepares inertly, activates reversible listeners, and starts only after commit', async () => { const config = Object.freeze({ clientSideBidders: Object.freeze(['rubicon']) }); const order: string[] = []; const start = vi.fn((received: unknown) => { order.push('start'); expect(received).toBe(config); }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('prebid:activate'); + return release; + }); let finishPreparation: (() => void) | undefined; const preparationGate = new Promise((resolve) => { finishPreparation = resolve; @@ -67,7 +72,7 @@ describe('transactional Prebid integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + interfaces: Object.freeze({ prebid: Object.freeze({ activate, start }) }), }), }); registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); @@ -87,9 +92,84 @@ describe('transactional Prebid integration module', () => { const result = await installing; expect(result).toMatchObject({ state: 'kernel' }); - expect(order).toEqual(['gate:prepare', 'core', 'gate:activate', 'publish', 'start', 'drain']); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'prebid:activate', + 'gate:activate', + 'publish', + 'start', + 'drain', + ]); + expect(activate).toHaveBeenCalledTimes(1); expect(start).toHaveBeenCalledExactlyOnceWith(config); - if (result.state === 'kernel') result.dispose(); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledTimes(1); + }); + + it('unwinds Prebid activation before fallback when a later module fails', async () => { + const release = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => release, start }), + }), + }), + }); + registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + throw new Error('fictional activation failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(release).toHaveBeenCalledTimes(1); + expect(start).not.toHaveBeenCalled(); + }); + + it('does not start when reversible Prebid activation fails', async () => { + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['prebid']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['prebid']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({}), + interfaces: Object.freeze({ + prebid: Object.freeze({ + activate: () => { + throw new Error('fictional listener activation failure'); + }, + start, + }), + }), + }), + }); + registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(start).not.toHaveBeenCalled(); }); it('fails preparation without effects when the composition omits the Prebid boundary', async () => { @@ -131,7 +211,9 @@ describe('transactional Prebid integration module', () => { now: () => 0, getBindings: () => ({ config, - interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); @@ -157,7 +239,9 @@ describe('transactional Prebid integration module', () => { onRuntimeFailure: (failure) => runtimeFailures.push(failure), getBindings: () => ({ config: Object.freeze({}), - interfaces: Object.freeze({ prebid: Object.freeze({ start }) }), + interfaces: Object.freeze({ + prebid: Object.freeze({ activate: () => vi.fn(), start }), + }), }), }); registry.register(createPrebidIntegrationRegistration(RELEASE_ID)); From 494e183f63129903cc9dea023cc59b706e493a9a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:12 -0700 Subject: [PATCH 348/494] Release Prebid bidder registrations explicitly --- .../lib/src/adapters/prebid.ts | 9 ++++---- .../lib/test/adapters/prebid.test.ts | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 123022701..26b7dfe1c 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -129,7 +129,7 @@ export interface PrebidFacade { registerBidAdapter(adapter: unknown, bidderCode: string, spec?: object): unknown; registerTrustedServerBidder( listener: (auction: Readonly) => void - ): unknown; + ): () => void; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; subscribe( @@ -957,7 +957,7 @@ export function createBrowserPrebidAdapter( listener: (auction: Readonly) => void, registerOperationEffect: (disposeEffect: () => void) => () => void, isOperationCurrent: () => boolean - ): unknown => { + ): (() => void) => { if (typeof listener !== 'function') { throw new TypeError('Trusted Server bidder listener must be a function'); } @@ -1043,12 +1043,13 @@ export function createBrowserPrebidAdapter( }); const bidderFactory = (): Readonly => bidder; try { - return callBound( + callBound( binding, 'registerBidAdapter', [bidderFactory, 'trustedServer'], isOperationCurrent ); + return release; } catch (error) { release(); throw error; @@ -1076,7 +1077,7 @@ export function createBrowserPrebidAdapter( ), registerTrustedServerBidder: ( listener: (auction: Readonly) => void - ): unknown => + ): (() => void) => registerTrustedServerBidder(binding, listener, registerOperationEffect, isOperationCurrent), renderAd: (targetDocument: object, adId: string): unknown => callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 10295f642..326e77051 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -1567,7 +1567,7 @@ describe('version-pinned Trusted Server bid admission', () => { const auctions: unknown[] = []; const operation = adapter.run((facade) => { const boundary = facade as unknown as { - registerTrustedServerBidder(listener: (auction: unknown) => void): unknown; + registerTrustedServerBidder(listener: (auction: unknown) => void): () => void; }; return boundary.registerTrustedServerBidder((auction) => auctions.push(auction)); }); @@ -1627,7 +1627,7 @@ describe('version-pinned Trusted Server bid admission', () => { it('captures one exact auction callback and admits a mutable copy atomically', async () => { const fixture = admissionFixture(); - await expect(fixture.operation.result).resolves.toBeUndefined(); + await expect(fixture.operation.result).resolves.toBeTypeOf('function'); expect(fixture.auctions).toHaveLength(1); const auction = fixture.auctions[0] as { @@ -1741,6 +1741,23 @@ describe('version-pinned Trusted Server bid admission', () => { fixture.adapter.dispose(); }); + it('releases the private bidder registration and permits exact replacement', async () => { + const fixture = admissionFixture(); + const release = await fixture.operation.result; + + expect(release).toBeTypeOf('function'); + Reflect.apply(release, undefined, []); + expect(fixture.done).toHaveBeenCalledTimes(1); + + const replacement = fixture.adapter.run((prebid) => + prebid.registerTrustedServerBidder(vi.fn()) + ); + const releaseReplacement = await replacement.result; + expect(releaseReplacement).toBeTypeOf('function'); + expect(fixture.ready.pbjs.registerBidAdapter).toHaveBeenCalledTimes(2); + Reflect.apply(releaseReplacement, undefined, []); + }); + it('throws a contract violation for partial publication and an ordinary callback throw otherwise', async () => { const partial = admissionFixture(); await partial.operation.result; From ce459b75754bb887106bf41c38f5084ffd346ed5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:12 -0700 Subject: [PATCH 349/494] Bridge Prebid startup into runtime ownership --- .../lib/src/integrations/prebid/startup.ts | 45 ++++++++++ .../test/integrations/prebid/startup.test.ts | 83 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/integrations/prebid/startup.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts new file mode 100644 index 000000000..8173b2589 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts @@ -0,0 +1,45 @@ +import type { + PrebidAdapter, + PrebidEventFacade, + PrebidTrustedServerAuctionV1, +} from '../../adapters/prebid'; + +export interface PrebidStartup { + readonly activate: () => () => void; + readonly start: (config: unknown) => void; +} + +export interface PrebidStartupOptions { + readonly dispose: () => void; + readonly onAuction: (auction: Readonly) => void; + readonly onAuctionEnd: (event: unknown, prebid: Readonly) => void; + readonly prebid: Pick; + readonly start?: (config: unknown) => void; +} + +/** Join the version-pinned Prebid callbacks to runtime-owned publication and selection state. */ +export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartup { + return Object.freeze({ + activate: (): (() => void) => { + const operation = options.prebid.run((prebid) => { + prebid.subscribe('auctionEnd', options.onAuctionEnd); + prebid.registerTrustedServerBidder(options.onAuction); + }); + void operation.result.catch(() => undefined); + let active = true; + return (): void => { + if (!active) return; + active = false; + try { + operation.dispose(); + } finally { + options.dispose(); + } + }; + }, + start: (config: unknown): void => { + options.start?.(config); + options.prebid.notifyReady(); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts new file mode 100644 index 000000000..c8a0dbba9 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + PrebidAdapter, + PrebidEventFacade, + PrebidFacade, + PrebidTrustedServerAuctionV1, +} from '../../../src/adapters/prebid'; +import { createPrebidStartup } from '../../../src/integrations/prebid/startup'; + +describe('Prebid startup bridge', () => { + it('installs one reversible bidder/event operation before starting the external boundary', async () => { + let bidderListener: ((auction: Readonly) => void) | undefined; + let auctionEndListener: + ((event: unknown, prebid: Readonly) => void) | undefined; + const operationDispose = vi.fn(); + const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn( + (listener: (auction: Readonly) => void) => { + bidderListener = listener; + } + ), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + auctionEndListener = listener; + return vi.fn(); + } + ), + }) as unknown as Readonly; + const run = vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }) + ); + const notifyReady = vi.fn(); + const adapter = Object.freeze({ run, notifyReady }) as unknown as PrebidAdapter; + const onAuction = vi.fn(); + const onAuctionEnd = vi.fn(); + const dispose = vi.fn(); + const start = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction, + onAuctionEnd, + prebid: adapter, + start, + }); + + const release = startup.activate(); + await Promise.resolve(); + + expect(run).toHaveBeenCalledTimes(1); + expect(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(facade.subscribe).toHaveBeenCalledTimes(1); + const auction = Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([]), + complete: vi.fn(), + }); + bidderListener?.(auction); + expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); + const event = Object.freeze({ auctionId: 'auction-one' }); + auctionEndListener?.(event, eventFacade); + expect(onAuctionEnd).toHaveBeenCalledExactlyOnceWith(event, eventFacade); + + const config = Object.freeze({ externalBundleUrl: '/prebid.js' }); + startup.start(config); + expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(notifyReady).toHaveBeenCalledTimes(1); + + release(); + release(); + expect(operationDispose).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); From 6c182bc68dedab570963e4872c249521ef497d23 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:56:27 -0700 Subject: [PATCH 350/494] Arm Prebid selection before bidder startup --- .../lib/src/integrations/prebid/startup.ts | 75 ++++++++++++++++--- .../test/integrations/prebid/startup.test.ts | 71 +++++++++++++++--- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts index 8173b2589..be5e8fa26 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts @@ -17,29 +17,80 @@ export interface PrebidStartupOptions { readonly start?: (config: unknown) => void; } -/** Join the version-pinned Prebid callbacks to runtime-owned publication and selection state. */ +/** Join the private bidder and early winner observer to one reversible runtime owner. */ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartup { + let activated = false; + let released = false; + let started = false; + let activationOperation: ReturnType | undefined; + let activationEffects: (() => void) | undefined; + let bidderOperation: ReturnType | undefined; + let bidderEffects: (() => void) | undefined; + + const retainEffects = ( + result: Promise, + publish: (release: () => void) => void + ): void => { + void result.then( + (candidate) => { + if (typeof candidate !== 'function') return; + if (released) candidate(); + else publish(candidate as () => void); + }, + () => undefined + ); + }; + + const disposeOwnedOperation = ( + operation: ReturnType | undefined, + releaseEffects: (() => void) | undefined + ): void => { + try { + operation?.dispose(); + } finally { + releaseEffects?.(); + } + }; + return Object.freeze({ activate: (): (() => void) => { - const operation = options.prebid.run((prebid) => { - prebid.subscribe('auctionEnd', options.onAuctionEnd); - prebid.registerTrustedServerBidder(options.onAuction); + if (activated || released) throw new Error('Prebid startup is already activated'); + activated = true; + activationOperation = options.prebid.run((prebid) => { + const releaseAuctionEnd = prebid.subscribe('auctionEnd', options.onAuctionEnd); + return releaseAuctionEnd; + }); + retainEffects(activationOperation.result, (release) => { + activationEffects = release; }); - void operation.result.catch(() => undefined); - let active = true; return (): void => { - if (!active) return; - active = false; + if (released) return; + released = true; try { - operation.dispose(); + disposeOwnedOperation(bidderOperation, bidderEffects); } finally { - options.dispose(); + try { + disposeOwnedOperation(activationOperation, activationEffects); + } finally { + options.dispose(); + } } }; }, start: (config: unknown): void => { - options.start?.(config); - options.prebid.notifyReady(); + if (!activated || released || started) throw new Error('Prebid startup is unavailable'); + started = true; + bidderOperation = options.prebid.run((prebid) => + prebid.registerTrustedServerBidder(options.onAuction) + ); + retainEffects(bidderOperation.result, (release) => { + bidderEffects = release; + }); + try { + options.start?.(config); + } finally { + options.prebid.notifyReady(); + } }, }); } diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts index c8a0dbba9..0431113ea 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -14,11 +14,19 @@ describe('Prebid startup bridge', () => { let auctionEndListener: ((event: unknown, prebid: Readonly) => void) | undefined; const operationDispose = vi.fn(); + const releaseBidder = vi.fn(); + const releaseAuctionEnd = vi.fn(); + const order: string[] = []; const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); const facade = Object.freeze({ registerTrustedServerBidder: vi.fn( (listener: (auction: Readonly) => void) => { + order.push('register-bidder'); bidderListener = listener; + return () => { + order.push('release-bidder'); + releaseBidder(); + }; } ), subscribe: vi.fn( @@ -27,8 +35,12 @@ describe('Prebid startup bridge', () => { listener: (event: unknown, prebid: Readonly) => void ) => { expect(eventType).toBe('auctionEnd'); + order.push('subscribe-auction-end'); auctionEndListener = listener; - return vi.fn(); + return () => { + order.push('release-auction-end'); + releaseAuctionEnd(); + }; } ), }) as unknown as Readonly; @@ -57,27 +69,68 @@ describe('Prebid startup bridge', () => { await Promise.resolve(); expect(run).toHaveBeenCalledTimes(1); - expect(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end']); + expect(facade.registerTrustedServerBidder).not.toHaveBeenCalled(); expect(facade.subscribe).toHaveBeenCalledTimes(1); - const auction = Object.freeze({ - auctionId: 'auction-one', - bids: Object.freeze([]), - complete: vi.fn(), - }); - bidderListener?.(auction); - expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); const event = Object.freeze({ auctionId: 'auction-one' }); auctionEndListener?.(event, eventFacade); expect(onAuctionEnd).toHaveBeenCalledExactlyOnceWith(event, eventFacade); const config = Object.freeze({ externalBundleUrl: '/prebid.js' }); startup.start(config); + await Promise.resolve(); expect(start).toHaveBeenCalledExactlyOnceWith(config); expect(notifyReady).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + expect(facade.registerTrustedServerBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual(['subscribe-auction-end', 'register-bidder']); + const auction = Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([]), + complete: vi.fn(), + }); + bidderListener?.(auction); + expect(onAuction).toHaveBeenCalledExactlyOnceWith(auction); release(); release(); + expect(operationDispose).toHaveBeenCalledTimes(2); + expect(releaseAuctionEnd).toHaveBeenCalledTimes(1); + expect(releaseBidder).toHaveBeenCalledTimes(1); + expect(order).toEqual([ + 'subscribe-auction-end', + 'register-bidder', + 'release-bidder', + 'release-auction-end', + ]); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('releases effects that settle after the runtime owner is already disposed', async () => { + let resolveOperation!: (release: () => void) => void; + const result = new Promise<() => void>((resolve) => { + resolveOperation = resolve; + }); + const operationDispose = vi.fn(); + const run = vi.fn(() => + Object.freeze({ status: 'present' as const, result, dispose: operationDispose }) + ); + const dispose = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid: Object.freeze({ run, notifyReady: vi.fn() }) as unknown as PrebidAdapter, + }); + const releaseEffects = vi.fn(); + + const release = startup.activate(); + release(); + resolveOperation(releaseEffects); + await Promise.resolve(); + expect(operationDispose).toHaveBeenCalledTimes(1); + expect(releaseEffects).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); }); }); From 9153a68acd9376619f5242abd0f7ddb0079ae186 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:56:27 -0700 Subject: [PATCH 351/494] Wire Prebid publication into browser composition --- .../lib/src/composition/browser.ts | 138 ++++++++++- .../lib/test/composition/browser.test.ts | 222 ++++++++++++++++++ 2 files changed, 347 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3b4539f80..8975b00d1 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -16,9 +16,11 @@ import { createNoopPrebidAdapter, type PrebidAdapter, type PrebidGlobalTarget, + type PrebidTrustedServerAuctionV1, } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; +import type { BrowserAuctionProjectionV1 } from '../core/types'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -42,8 +44,18 @@ import { type GptWinnerPublicationResult, } from '../integrations/gpt/module'; import { createGptStartup } from '../integrations/gpt/startup'; +import { + createPrebidSelectionCoordinator, + publishPrebidBid, + type PrebidSelectionCoordinator, +} from '../integrations/prebid/module'; +import { createPrebidStartup } from '../integrations/prebid/startup'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; -import type { NavigationIdentityIssuerFactory, RuntimeSession } from '../kernel/sessions'; +import type { + NavigationIdentityIssuerFactory, + RenderAttemptScope, + RuntimeSession, +} from '../kernel/sessions'; import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; @@ -161,6 +173,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; readonly prebidStartupForTest?: (config: unknown) => void; + readonly pucSchedulerForTest?: PucBridgeOptions['scheduler']; } interface AcceptedBrowserBoot { @@ -172,6 +185,7 @@ interface AcceptedBrowserBoot { } interface PreparedBrowserServices { + readonly createAttempt: (owner: RenderAttemptScope) => ReturnType; readonly publisherOrigin: string; readonly rendererUrl: string; readonly resolveCacheAdm: NonNullable; @@ -265,9 +279,77 @@ export function createTestBrowserRuntimeComposition( }, start: startGpt, }); - const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); - const prebidRuntime = Object.freeze({ start: startPrebid }); let runtimeSession: RuntimeSession | undefined; + let prebidCoordinator: PrebidSelectionCoordinator | undefined; + const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); + const completePrebidAuction = (auction: Readonly): void => { + try { + auction.complete(); + } catch { + // The private bidder completion boundary cannot escape into publisher code. + } + }; + const publishPrebidAuction = (auction: Readonly): void => { + const navigation = runtimeSession?.currentNavigation; + const reservations = browserServices?.reservations; + const coordinator = prebidCoordinator; + if (!navigation || !reservations || !coordinator || !navigation.isCurrent()) { + completePrebidAuction(auction); + return; + } + try { + const projection = navigation.currentAuctionProjection as + Readonly | undefined; + if (!projection || projection.auction.auctionId !== auction.auctionId) return; + for (let index = 0; index < auction.bids.length; index += 1) { + const request = auction.bids[index]; + if (!request) continue; + const winners = projection.auction.results.filter( + (result) => result.slot === request.adUnitCode && result.outcome === 'winner' + ); + if (winners.length !== 1) continue; + const winner = winners[0]; + if (!winner || winner.outcome !== 'winner') continue; + const bids = projection.bids.filter( + (bid) => bid.slot === request.adUnitCode && bid.candidateId === winner.candidateId + ); + if (bids.length !== 1) continue; + const bid = bids[0]; + if (!bid) continue; + publishPrebidBid({ + admitTrustedBid: (preparedBid) => + composition.adapters.prebid.admitTrustedBid(preparedBid), + auctionId: auction.auctionId, + adUnitCode: request.adUnitCode, + bid, + generatedBid: Object.freeze({ + requestId: request.requestId, + adId: request.requestId, + cpm: bid.cpm, + width: bid.renderSource.width, + height: bid.renderSource.height, + }), + navigation, + reservations, + trackAdmittedBid: coordinator.track, + }); + } + } catch { + // Invalid/stale projection state publishes no Prebid bid. + } finally { + completePrebidAuction(auction); + } + }; + const prebidRuntime = createPrebidStartup({ + dispose: () => { + prebidCoordinator?.dispose(); + prebidCoordinator = undefined; + }, + onAuction: publishPrebidAuction, + onAuctionEnd: (event, prebid) => prebidCoordinator?.auctionEnded(event, prebid), + prebid: composition.adapters.prebid, + start: startPrebid, + }); const getBindings: NonNullable = (id) => { const provided = providedBindings?.(id); let config: unknown; @@ -620,18 +702,19 @@ export function createTestBrowserRuntimeComposition( } }; const fetchAuction = compositionOptions.auctionFetcherForTest ?? globalThis.fetch; + const createOwnedAttempt = (owner: RenderAttemptScope) => + createRenderAttempt({ + artifacts, + owner, + prepareRenderSource: (candidate) => { + const source = parseBidRenderSourceV1(candidate, cachePolicy); + return source ? Object.freeze(source) : undefined; + }, + reservations: reservationService, + }); const batchCoordinator = createAuctionBatchService({ ...(cachePolicy ? { cachePolicy } : {}), - createAttempt: (owner) => - createRenderAttempt({ - artifacts, - owner, - prepareRenderSource: (candidate) => { - const source = parseBidRenderSourceV1(candidate, cachePolicy); - return source ? Object.freeze(source) : undefined; - }, - reservations: reservationService, - }), + createAttempt: createOwnedAttempt, fetcher: (input, init) => { if (typeof fetchAuction !== 'function') return Promise.reject(new Error('unavailable')); return fetchAuction(input, init); @@ -663,6 +746,7 @@ export function createTestBrowserRuntimeComposition( targeting: targetingService, }); preparedBrowserServices = Object.freeze({ + createAttempt: createOwnedAttempt, publisherOrigin, rendererUrl, resolveCacheAdm, @@ -732,6 +816,9 @@ export function createTestBrowserRuntimeComposition( const pucBridge = createPucBridge({ messaging: composition.adapters.messaging, publisherOrigin: prepared.publisherOrigin, + ...(compositionOptions.pucSchedulerForTest + ? { scheduler: compositionOptions.pucSchedulerForTest } + : {}), rendererNonces: prepared.services.rendererNonces, rendererUrl: prepared.rendererUrl, reservations: prepared.services.reservations, @@ -740,6 +827,31 @@ export function createTestBrowserRuntimeComposition( }); context.onDispose(() => pucBridge.dispose()); browserServices = Object.freeze({ ...prepared.services, pucBridge }); + const coordinator = createPrebidSelectionCoordinator({ + activateAttempt: ({ attempt, owner, preparedBid }): boolean => { + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }); + const input = Object.freeze({ + artifact, + attempt, + owner, + reservationId: preparedBid.bid.adId, + }); + return pucBridge.registerGamAttempt(input); + }, + createAttempt: prepared.createAttempt, + reservations: prepared.services.reservations, + }); + prebidCoordinator = coordinator; + context.onDispose(() => { + coordinator.dispose(); + if (prebidCoordinator === coordinator) prebidCoordinator = undefined; + }); browserServices.slots.activate(); compositionOptions.coreActivations.correctnessGptListeners( context, diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 7ab2c9811..ee57a3e28 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -8,6 +8,7 @@ import { type GoogletagFacade, } from '../../src/adapters/googletag'; import { + createBrowserMessagingAdapter, createNoopMessagingAdapter, type CaptureMessageListener, type MessagingAdapter, @@ -16,6 +17,10 @@ import { createNoopPrebidAdapter, type PrebidAdapter, type PrebidBindingStatus, + type PrebidEventFacade, + type PrebidFacade, + type PrebidTrustedServerAuctionV1, + type PreparedTrustedBidV1, } from '../../src/adapters/prebid'; import { createBrowserComposition, @@ -116,6 +121,79 @@ function fakePrebidAdapter( return Object.freeze({ ...createNoopPrebidAdapter(), bindingStatus }); } +function synchronousPrebidAdapter() { + let auctionListener: ((auction: Readonly) => void) | undefined; + let auctionEndListener: + ((event: unknown, prebid: Readonly) => void) | undefined; + let admitted: Readonly | undefined; + const admitTrustedBid = vi.fn((prepared: Readonly) => { + admitted = prepared; + return 'admitted' as const; + }); + const facade = Object.freeze({ + addAdUnits: vi.fn(), + highestBids: vi.fn(() => Object.freeze([])), + processQueue: vi.fn(), + registerBidAdapter: vi.fn(), + registerTrustedServerBidder: vi.fn( + (listener: (auction: Readonly) => void) => { + auctionListener = listener; + return () => { + auctionListener = undefined; + }; + } + ), + renderAd: vi.fn(), + requestBids: vi.fn(), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + if (eventType === 'auctionEnd') auctionEndListener = listener; + return () => { + if (auctionEndListener === listener) auctionEndListener = undefined; + }; + } + ), + }) satisfies PrebidFacade; + const adapter = Object.freeze({ + ...createNoopPrebidAdapter(), + admitTrustedBid, + bindingStatus: () => 'present' as const, + run: (command: (prebid: Readonly) => Value) => { + let result: Promise; + try { + result = Promise.resolve(command(facade)); + } catch (error) { + result = Promise.reject(error); + } + return Object.freeze({ status: 'present' as const, result, dispose: vi.fn() }); + }, + }) satisfies PrebidAdapter; + return { + adapter, + admitTrustedBid, + auction: (auction: Readonly): void => auctionListener?.(auction), + auctionEnd: (auctionId: string): void => { + const prepared = admitted; + const highest = prepared + ? Object.freeze([ + Object.freeze({ + ...prepared.bid, + adUnitCode: prepared.adUnitCode, + auctionId: prepared.auctionId, + }), + ]) + : Object.freeze([]); + auctionEndListener?.( + Object.freeze({ auctionId }), + Object.freeze({ highestBids: () => highest }) + ); + }, + }; +} + function fakeMessagingAdapter( installCaptureListener: MessagingAdapter['installCaptureListener'] = () => vi.fn() ): MessagingAdapter { @@ -674,6 +752,150 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + it('publishes and promotes one exact Prebid winner through runtime-owned PUC state', async () => { + const releaseId = 'a'.repeat(64); + const prebid = synchronousPrebidAdapter(); + const reservationId = `r1_${'p'.repeat(22)}`; + let captureListener: CaptureMessageListener | undefined; + const messagingTarget = { + addEventListener: vi.fn( + (_type: 'message', listener: CaptureMessageListener, _capture: true) => { + captureListener = listener; + } + ), + removeEventListener: vi.fn(), + }; + const messaging = createBrowserMessagingAdapter(messagingTarget); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-one', + provider: 'trusted', + upstreamBidId: 'upstream-one', + cpm: 1.25, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
private creative
', + width: 300, + height: 250, + }), + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'auction-one', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'prebid', required: true }], + }, + knownIntegrationIds: Object.freeze(['prebid']), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging, + prebid: prebid.adapter, + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(9); + return target; + }, + }), + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createPrebidIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const complete = vi.fn(); + prebid.auction( + Object.freeze({ + auctionId: 'auction-one', + bids: Object.freeze([Object.freeze({ adUnitCode: bid.slot, requestId: 'request-one' })]), + complete, + }) + ); + + expect(complete).toHaveBeenCalledTimes(1); + expect(prebid.admitTrustedBid).toHaveBeenCalledTimes(1); + expect(prebid.admitTrustedBid.mock.calls[0]?.[0]).toMatchObject({ + auctionId: 'auction-one', + adUnitCode: bid.slot, + bid: { adId: reservationId, requestId: 'request-one' }, + }); + expect(composition.reservationServiceForTest()?.recognize(reservationId)).toMatchObject({ + state: 'awaiting_prebid_selection', + }); + + prebid.auctionEnd('auction-one'); + expect(composition.reservationServiceForTest()?.recognize(reservationId)).toMatchObject({ + state: 'renderable', + }); + expect(composition.pucBridgeForTest()?.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + }); + + const claimPort = { + addEventListener: vi.fn(), + close: vi.fn(), + postMessage: vi.fn(), + removeEventListener: vi.fn(), + start: vi.fn(), + }; + captureListener?.({ + data: JSON.stringify({ + message: 'Prebid Request', + adId: reservationId, + adServerDomain: 'ads.example.com', + }), + ports: [claimPort], + source: Object.freeze({ frame: 'selected-creative' }), + stopImmediatePropagation: vi.fn(), + } as unknown as MessageEvent); + expect(composition.pucBridgeForTest()?.snapshotInventoryForTest()).toMatchObject({ + attempts: 1, + liveTickets: 0, + pendingClaims: 1, + }); + expect(claimPort.postMessage).not.toHaveBeenCalled(); + expect(claimPort.close).not.toHaveBeenCalled(); + } finally { + composition.runtime.dispose(); + } + }); + it('hands late publisher GPT calls through the adapter into runtime-owned slot state', async () => { const releaseId = 'a'.repeat(64); const slot = Object.freeze({ id: 'trusted-slot' }); From e1aa8b7f8b197bcc51fe063a73d39e3bfc6022e9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:05:29 -0700 Subject: [PATCH 352/494] Establish creative integration ownership --- .../lib/src/integrations/creative/module.ts | 113 +++++++ .../lib/src/integrations/creative/startup.ts | 120 ++++++++ .../test/integrations/creative/module.test.ts | 276 ++++++++++++++++++ .../integrations/creative/startup.test.ts | 172 +++++++++++ 4 files changed, 681 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/integrations/creative/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/creative/startup.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/creative/module.ts b/crates/trusted-server-js/lib/src/integrations/creative/module.ts new file mode 100644 index 000000000..f797267b9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/module.ts @@ -0,0 +1,113 @@ +import type { CreativeBootV1 } from '../../core/types'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +export const CREATIVE_INTEGRATION_ID = 'creative' as const; + +interface CreativeIntegrationRuntime { + readonly activate: (config: Readonly) => () => void; + readonly start: (config: Readonly) => void; +} + +function readCreativeBoot(candidate: unknown): Readonly | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return undefined; + } + const keys = Object.getOwnPropertyNames(candidate).sort(); + const expected = ['clickGuard', 'enabled', 'renderGuard', 'version']; + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + return undefined; + } + const values: Record = {}; + for (let index = 0; index < expected.length; index += 1) { + const key = expected[index]; + if (!key) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; + values[key] = descriptor.value; + } + return values['version'] === 1 && + typeof values['enabled'] === 'boolean' && + typeof values['clickGuard'] === 'boolean' && + typeof values['renderGuard'] === 'boolean' + ? (candidate as Readonly) + : undefined; + } catch { + return undefined; + } +} + +function readCreativeRuntime( + interfaces: Readonly> +): CreativeIntegrationRuntime | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, CREATIVE_INTEGRATION_ID); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 2 + ) { + return undefined; + } + const activate = Object.getOwnPropertyDescriptor(candidate, 'activate'); + const start = Object.getOwnPropertyDescriptor(candidate, 'start'); + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } + return candidate as CreativeIntegrationRuntime; + } catch { + return undefined; + } +} + +/** Build the inert, release-bound creative module for the coordinated runtime. */ +export function createCreativeIntegrationRegistration(release: string): IntegrationRegistration { + return Object.freeze({ + id: CREATIVE_INTEGRATION_ID, + release, + prepare: async ({ config, interfaces }: IntegrationPrepareContext) => { + const creative = readCreativeBoot(config); + if (!creative) throw new TypeError('Creative boot configuration is invalid'); + const runtime = readCreativeRuntime(interfaces); + if (!runtime) throw new TypeError('Creative integration runtime is unavailable'); + if (!creative.enabled || (!creative.clickGuard && !creative.renderGuard)) { + return Object.freeze({ activate: () => undefined }); + } + + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const releaseRuntime = runtime.activate(creative); + if (typeof releaseRuntime !== 'function') { + throw new TypeError('Creative integration activation disposer is unavailable'); + } + runtimeRelease.value = releaseRuntime; + afterCommit(() => runtime.start(creative)); + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/creative/startup.ts b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts new file mode 100644 index 000000000..cf5167612 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts @@ -0,0 +1,120 @@ +import type { CreativeBootV1 } from '../../core/types'; + +export interface CreativeGuardHandle { + readonly dispose: () => void; + readonly scan: () => void; +} + +export interface CreativeStartup { + readonly activate: (config: Readonly) => () => void; + readonly start: (config: Readonly) => void; +} + +export interface CreativeStartupOptions { + readonly document: { + readonly readyState: DocumentReadyState; + addEventListener(type: 'DOMContentLoaded', listener: () => void, options: { once: true }): void; + removeEventListener(type: 'DOMContentLoaded', listener: () => void): void; + }; + readonly installClickGuard: () => CreativeGuardHandle; + readonly installDynamicIframeProxy: () => CreativeGuardHandle; + readonly installDynamicImageProxy: () => CreativeGuardHandle; +} + +function sameBoot(left: Readonly, right: Readonly): boolean { + return ( + left.version === right.version && + left.enabled === right.enabled && + left.clickGuard === right.clickGuard && + left.renderGuard === right.renderGuard + ); +} + +function validHandle(candidate: unknown): candidate is CreativeGuardHandle { + return ( + typeof candidate === 'object' && + candidate !== null && + typeof Reflect.get(candidate, 'dispose') === 'function' && + typeof Reflect.get(candidate, 'scan') === 'function' + ); +} + +/** Own creative guard installation separately from the post-commit initial scan. */ +export function createCreativeStartup(options: CreativeStartupOptions): CreativeStartup { + const handles: CreativeGuardHandle[] = []; + let activated = false; + let activatedBoot: Readonly | undefined; + let readyListener: (() => void) | undefined; + let released = false; + let started = false; + + const scan = (): void => { + if (released) return; + for (let index = 0; index < handles.length; index += 1) { + try { + handles[index]?.scan(); + } catch { + // One hostile guard scan cannot suppress the remaining active guards. + } + } + }; + + const disposeHandles = (): void => { + for (let index = handles.length - 1; index >= 0; index -= 1) { + try { + handles[index]?.dispose(); + } catch { + // Continue releasing every previously installed guard. + } + } + handles.length = 0; + }; + + const install = (installer: () => CreativeGuardHandle): void => { + const handle = installer(); + if (!validHandle(handle)) throw new TypeError('Creative guard handle is invalid'); + handles.push(handle); + }; + + return Object.freeze({ + activate: (config: Readonly): (() => void) => { + if (activated || released) throw new Error('Creative startup is already activated'); + activated = true; + activatedBoot = config; + try { + if (config.enabled && config.clickGuard) install(options.installClickGuard); + if (config.enabled && config.renderGuard) { + install(options.installDynamicImageProxy); + install(options.installDynamicIframeProxy); + } + if (handles.length > 0 && options.document.readyState === 'loading') { + readyListener = () => scan(); + options.document.addEventListener('DOMContentLoaded', readyListener, { once: true }); + } + } catch (error) { + disposeHandles(); + throw error; + } + return (): void => { + if (released) return; + released = true; + const listener = readyListener; + readyListener = undefined; + try { + if (listener) options.document.removeEventListener('DOMContentLoaded', listener); + } finally { + disposeHandles(); + } + }; + }, + start: (config: Readonly): void => { + if (started) throw new Error('Creative startup is already started'); + started = true; + if (released) return; + if (!activated || !activatedBoot || !sameBoot(activatedBoot, config)) { + throw new Error('Creative startup is unavailable'); + } + if (handles.length > 0 && options.document.readyState !== 'loading') scan(); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts new file mode 100644 index 000000000..22393ce85 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createCreativeIntegrationRegistration } from '../../../src/integrations/creative/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, + type IntegrationRegistration, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true })), + }; +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function registration( + id: string, + prepare: IntegrationRegistration['prepare'] +): IntegrationRegistration { + return Object.freeze({ id, release: RELEASE_ID, prepare }); +} + +describe('transactional creative integration module', () => { + it('prepares inertly, activates reversible guards, and scans only after commit', async () => { + const config = Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: true, + }); + const order: string[] = []; + const release = vi.fn(() => order.push('release')); + const activate = vi.fn((received: unknown) => { + order.push('creative:activate'); + expect(received).toBe(config); + return release; + }); + const start = vi.fn(() => order.push('creative:scan')); + let finishPreparation: (() => void) | undefined; + const preparationGate = new Promise((resolve) => { + finishPreparation = resolve; + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative', 'gate']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative', 'gate']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ creative: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('gate', async () => { + order.push('gate:prepare'); + await preparationGate; + return Object.freeze({ activate: () => order.push('gate:activate') }); + }) + ); + + const installing = registry.install(callbacks(order)); + await vi.waitFor(() => expect(order).toEqual(['gate:prepare'])); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + + finishPreparation?.(); + const result = await installing; + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'gate:prepare', + 'core', + 'creative:activate', + 'gate:activate', + 'publish', + 'creative:scan', + 'drain', + ]); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each([ + Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: true }), + Object.freeze({ version: 1, enabled: true, clickGuard: false, renderGuard: false }), + ])('performs no runtime work for an inactive creative boot %#', async (config) => { + const activate = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ creative: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ state: 'kernel' }); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + }); + + it('unwinds creative activation before a later module failure', async () => { + const release = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative', 'broken']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative', 'broken']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({ + creative: Object.freeze({ activate: () => release, start }), + }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + registry.register( + registration('broken', () => ({ + activate: () => { + throw new Error('fictional creative peer failure'); + }, + })) + ); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(release).toHaveBeenCalledTimes(1); + expect(start).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing field', Object.freeze({ version: 1, enabled: true, clickGuard: true })], + [ + 'unknown field', + Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + extra: true, + }), + ], + [ + 'accessor', + Object.freeze( + Object.defineProperty({ version: 1, enabled: true, clickGuard: true }, 'renderGuard', { + enumerable: true, + get: () => false, + }) + ), + ], + [ + 'non-plain object', + Object.freeze( + Object.assign(Object.create({ inherited: true }) as object, { + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }) + ), + ], + ['mutable object', { version: 1, enabled: true, clickGuard: true, renderGuard: false }], + ])('rejects %s configuration during inert preparation', async (_caseName, config) => { + const activate = vi.fn(); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ creative: Object.freeze({ activate, start }) }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + }); + + it('fails preparation without effects when composition omits the creative boundary', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({}), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); + + it('isolates a post-commit scan failure to the creative module', async () => { + const runtimeFailures: unknown[] = []; + const start = vi.fn(() => { + throw new Error('fictional creative scan failure'); + }); + const registry = createIntegrationRegistry({ + manifest: manifest(['creative']), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['creative']), + startedAtMs: 0, + now: () => 0, + onRuntimeFailure: (failure) => runtimeFailures.push(failure), + getBindings: () => ({ + config: Object.freeze({ + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }), + interfaces: Object.freeze({ + creative: Object.freeze({ activate: () => vi.fn(), start }), + }), + }), + }); + registry.register(createCreativeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'kernel', + runtimeFailures: [{ id: 'creative', phase: 'after_commit' }], + }); + expect(runtimeFailures).toEqual([{ id: 'creative', phase: 'after_commit' }]); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts new file mode 100644 index 000000000..8a7bf7a30 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CreativeBootV1 } from '../../../src/core/types'; +import { + createCreativeStartup, + type CreativeGuardHandle, +} from '../../../src/integrations/creative/startup'; + +function config(overrides: Partial = {}): Readonly { + return Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: true, + renderGuard: true, + ...overrides, + }); +} + +function guard(name: string, order: string[]): CreativeGuardHandle { + return Object.freeze({ + dispose: vi.fn(() => order.push(`dispose:${name}`)), + scan: vi.fn(() => order.push(`scan:${name}`)), + }); +} + +function readyDocument(readyState: DocumentReadyState = 'complete') { + let listener: (() => void) | undefined; + return { + document: { + readyState, + addEventListener: vi.fn( + (_type: 'DOMContentLoaded', next: () => void, _options: { once: true }) => { + listener = next; + } + ), + removeEventListener: vi.fn((_type: 'DOMContentLoaded', candidate: () => void) => { + if (listener === candidate) listener = undefined; + }), + }, + dispatchReady: (): void => { + const current = listener; + listener = undefined; + current?.(); + }, + }; +} + +describe('creative startup ownership', () => { + it('installs selected guards synchronously, scans after commit, and disposes in reverse', async () => { + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const iframe = guard('iframe', order); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: vi.fn(() => (order.push('install:click'), click)), + installDynamicImageProxy: vi.fn(() => (order.push('install:image'), image)), + installDynamicIframeProxy: vi.fn(() => (order.push('install:iframe'), iframe)), + }); + const boot = config(); + + const release = startup.activate(boot); + expect(order).toEqual(['install:click', 'install:image', 'install:iframe']); + + startup.start(boot); + expect(order).toEqual([ + 'install:click', + 'install:image', + 'install:iframe', + 'scan:click', + 'scan:image', + 'scan:iframe', + ]); + + release(); + release(); + expect(order.slice(-3)).toEqual(['dispose:iframe', 'dispose:image', 'dispose:click']); + }); + + it('owns one loading-document rescan and removes it on disposal', async () => { + const order: string[] = []; + const click = guard('click', order); + const target = readyDocument('loading'); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => guard('image', order), + installDynamicIframeProxy: () => guard('iframe', order), + }); + const boot = config({ renderGuard: false }); + + const release = startup.activate(boot); + expect(target.document.addEventListener).toHaveBeenCalledExactlyOnceWith( + 'DOMContentLoaded', + expect.any(Function), + { once: true } + ); + startup.start(boot); + expect(click.scan).not.toHaveBeenCalled(); + + target.dispatchReady(); + target.dispatchReady(); + expect(click.scan).toHaveBeenCalledTimes(1); + + release(); + expect(target.document.removeEventListener).toHaveBeenCalledTimes(1); + expect(click.dispose).toHaveBeenCalledTimes(1); + }); + + it('rolls back earlier guards when a later installer throws', async () => { + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => image, + installDynamicIframeProxy: () => { + throw new Error('fictional iframe installation failure'); + }, + }); + + expect(() => startup.activate(config())).toThrow('fictional iframe installation failure'); + expect(order).toEqual(['dispose:image', 'dispose:click']); + }); + + it('contains hostile scans and still visits every active guard', async () => { + const order: string[] = []; + const click = guard('click', order); + const image = guard('image', order); + const iframe = guard('iframe', order); + vi.mocked(click.scan).mockImplementation(() => { + order.push('scan:click'); + throw new Error('fictional click scan failure'); + }); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => image, + installDynamicIframeProxy: () => iframe, + }); + const boot = config(); + startup.activate(boot); + + expect(() => startup.start(boot)).not.toThrow(); + expect(image.scan).toHaveBeenCalledTimes(1); + expect(iframe.scan).toHaveBeenCalledTimes(1); + }); + + it('prevents a late start after release and rejects duplicate lifecycle calls', async () => { + const order: string[] = []; + const click = guard('click', order); + const target = readyDocument(); + const startup = createCreativeStartup({ + document: target.document, + installClickGuard: () => click, + installDynamicImageProxy: () => guard('image', order), + installDynamicIframeProxy: () => guard('iframe', order), + }); + const boot = config({ renderGuard: false }); + const release = startup.activate(boot); + expect(() => startup.activate(boot)).toThrow('already activated'); + release(); + + startup.start(boot); + expect(click.scan).not.toHaveBeenCalled(); + expect(() => startup.start(boot)).toThrow('already started'); + }); +}); From f71f480e9627ccc70941c1793a6d1b5e5f9ffa5c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:12:32 -0700 Subject: [PATCH 353/494] Own the creative guard lifecycle --- .../lib/src/integrations/creative/click.ts | 68 +- .../creative/dynamic_src_guard.ts | 613 +++++++++++------- .../lib/src/integrations/creative/iframe.ts | 5 +- .../lib/src/integrations/creative/image.ts | 5 +- .../lib/src/integrations/creative/index.ts | 28 +- .../lib/src/shared/scheduler.ts | 25 +- .../test/integrations/creative/click.test.ts | 40 +- .../lib/test/integrations/creative/helpers.ts | 12 +- .../test/integrations/creative/iframe.test.ts | 24 +- .../test/integrations/creative/image.test.ts | 30 +- .../integrations/creative/ownership.test.ts | 110 ++++ .../lib/test/shared/scheduler.test.ts | 14 + 12 files changed, 706 insertions(+), 268 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index f350c4a65..a1e496705 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -5,6 +5,8 @@ import { delay, queueTask } from '../../shared/async'; import { hasOpaqueOrigin, TRUSTED_BASE_URL } from '../../shared/origin'; import { createMutationScheduler } from '../../shared/scheduler'; +import type { CreativeGuardHandle } from './startup'; + type AnchorLike = HTMLAnchorElement | HTMLAreaElement; type Canon = { base: string; params: Record }; type Diff = { add: Record; del: string[] }; @@ -347,9 +349,11 @@ async function rebuildIfNeeded(anchor: AnchorLike, tsClickStr: string): Promise< async function guardNavigation( anchor: AnchorLike, tsClickStr: string, - isMiddle: boolean + isMiddle: boolean, + isActive: () => boolean ): Promise { const finalUrl = await rebuildIfNeeded(anchor, tsClickStr); + if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { persistRebuiltClick(anchor, finalUrl); } @@ -357,7 +361,7 @@ async function guardNavigation( } // Entry point for click/auxclick handlers: prevent default and queue guarded nav. -function handleGuardedClick(ev: Event, isMiddle: boolean): void { +function handleGuardedClick(ev: Event, isMiddle: boolean, isActive: () => boolean): void { const anchor = closestAnchor(ev.target); if (!anchor) return; @@ -367,7 +371,9 @@ function handleGuardedClick(ev: Event, isMiddle: boolean): void { ev.preventDefault(); const runNavigation = () => { - void guardNavigation(anchor, tsClickStr, isMiddle).catch((err) => { + if (!isActive()) return; + void guardNavigation(anchor, tsClickStr, isMiddle, isActive).catch((err) => { + if (!isActive()) return; log.warn('tsjs-creative:click: failed to compute final URL', err); navigate(anchor, tsClickStr, isMiddle); }); @@ -377,14 +383,18 @@ function handleGuardedClick(ev: Event, isMiddle: boolean): void { } // Observe href/data-tsclick mutations and repair anchors that third parties touch. -function monitorAnchorMutations(): void { - if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return; +function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { + if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { + return Object.freeze({ dispose: () => undefined, scan: () => undefined }); + } const schedule = createMutationScheduler((anchor) => { + if (!isActive()) return; const tsClickStr = anchor.getAttribute('data-tsclick') || ''; if (!tsClickStr) return; void rebuildIfNeeded(anchor, tsClickStr) .then((finalUrl) => { + if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { persistRebuiltClick(anchor, finalUrl); } @@ -394,14 +404,14 @@ function monitorAnchorMutations(): void { }); }); - const scan = () => { + const scan = (): void => { + if (!isActive()) return; const anchors = document.querySelectorAll('a[data-tsclick], area[data-tsclick]'); anchors.forEach((anchor) => schedule(anchor)); }; - scan(); - const observer = new MutationObserver((records) => { + if (!isActive()) return; for (const record of records) { if (record.type !== 'attributes') continue; const target = record.target; @@ -416,27 +426,61 @@ function monitorAnchorMutations(): void { attributes: true, attributeFilter: ['href', 'data-tsclick'], }); + + let disposed = false; + return Object.freeze({ + dispose: (): void => { + if (disposed) return; + disposed = true; + observer.disconnect(); + schedule.dispose(); + }, + scan, + }); } // Wire up capture-phase click handlers + mutation observers to protect clicks. -export function installClickGuard(): void { +export function installClickGuard(scanInitially = true): CreativeGuardHandle { if (log.getLevel && log.getLevel() === 'warn') { log.setLevel('info'); } enableDebugFromEnv(); log.info('tsjs-creative:click: installing click guard'); + let active = true; + const isActive = (): boolean => active; const onClick = (ev: Event) => { - handleGuardedClick(ev, false); + if (!active) return; + handleGuardedClick(ev, false, isActive); }; const onAuxClick = (ev: MouseEvent) => { + if (!active) return; if (ev.button !== 1) return; - handleGuardedClick(ev, true); + handleGuardedClick(ev, true, isActive); }; document.addEventListener('click', onClick, true); document.addEventListener('auxclick', onAuxClick as EventListener, true); - monitorAnchorMutations(); + let mutations: CreativeGuardHandle | undefined; + const dispose = (): void => { + if (!active) return; + active = false; + document.removeEventListener('click', onClick, true); + document.removeEventListener('auxclick', onAuxClick as EventListener, true); + mutations?.dispose(); + }; + try { + mutations = monitorAnchorMutations(isActive); + const handle = Object.freeze({ + dispose, + scan: (): void => mutations?.scan(), + }); + if (scanInitially) handle.scan(); + return handle; + } catch (error) { + dispose(); + throw error; + } } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts index 0e8d4cb84..386b0582c 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts @@ -1,5 +1,7 @@ import { log } from '../../core/log'; -import { createMutationScheduler } from '../../shared/scheduler'; +import { createMutationScheduler, type MutationScheduler } from '../../shared/scheduler'; + +import type { CreativeGuardHandle } from './startup'; type ElementWithSrc = Element & { src: string }; @@ -14,6 +16,11 @@ type FactoryFunction = { new (...args: unknown[]): E; } & ((...args: unknown[]) => E); +interface InstancePatch { + readonly installed: PropertyDescriptor; + readonly original: PropertyDescriptor | undefined; +} + export interface DynamicSrcProxyOptions { elementConstructor: ElementCtor | undefined; selector: string; @@ -26,301 +33,423 @@ export interface DynamicSrcProxyOptions { signProxy(raw: string, element: E): Promise; } +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + if (!left || !right) return left === right; + return ( + left.configurable === right.configurable && + left.enumerable === right.enumerable && + left.get === right.get && + left.set === right.set && + left.value === right.value && + left.writable === right.writable + ); +} + +function inertHandle(): CreativeGuardHandle { + return Object.freeze({ dispose: () => undefined, scan: () => undefined }); +} + export function createDynamicSrcProxy( options: DynamicSrcProxyOptions -): () => void { +): (scanInitially?: boolean) => CreativeGuardHandle { const attr = (options.attributeName ?? 'src').toLowerCase(); const tagName = options.tagName.toLowerCase(); + let installedHandle: CreativeGuardHandle | undefined; - const assignments = new WeakMap(); - const lastProcessed = new WeakMap(); - let sequence = 0; - let proxyInstalled = false; - let observerInstalled = false; - let nativeSet: ((this: E, value: string) => void) | undefined; - let nativeGet: ((this: E) => string) | undefined; - let nativeSetAttribute: (this: E, name: string, value: string) => void = () => undefined; - let nativeSetAttributeNS: - ((this: E, namespace: string | null, name: string, value: string) => void) | undefined; - const wrappedInstances = new WeakSet(); - let createElementPatched = false; - let factoryPatched = false; - const nativeCreateElement = - typeof document === 'undefined' ? undefined : document.createElement.bind(document); + return function install(scanInitially = true): CreativeGuardHandle { + if (installedHandle) return installedHandle; + const ctor = options.elementConstructor; + if (typeof ctor !== 'function') { + installedHandle = inertHandle(); + return installedHandle; + } - function apply(element: E, value: string): void { - try { - if (typeof nativeSet === 'function') { - nativeSet.call(element, value); - } else { - nativeSetAttribute.call(element, attr, value); - } - } catch (err) { - log.debug(`${options.logPrefix}: failed to apply ${options.resourceName} ${attr}`, err); + const sourceDescriptor = Object.getOwnPropertyDescriptor(ctor.prototype, attr); + if (!sourceDescriptor || typeof sourceDescriptor.set !== 'function') { + log.debug(`${options.logPrefix}: ${ctor.name} proxy install skipped (no setter)`); + installedHandle = inertHandle(); + return installedHandle; } - } - function proxyAssignment(element: E, rawInput: string): void { - const raw = String(rawInput || ''); - const last = lastProcessed.get(element); - if (last === raw) return; - lastProcessed.set(element, raw); + const assignments = new WeakMap(); + const lastProcessed = new WeakMap(); + const instancePatches = new Map(); + const nativeSet = sourceDescriptor.set as (this: E, value: string) => void; + const nativeGet = + typeof sourceDescriptor.get === 'function' + ? (sourceDescriptor.get as (this: E) => string) + : undefined; + const nativeSetAttribute = ctor.prototype.setAttribute as ( + this: E, + name: string, + value: string + ) => void; + const nativeSetAttributeNS = + typeof ctor.prototype.setAttributeNS === 'function' + ? (ctor.prototype.setAttributeNS as ( + this: E, + namespace: string | null, + name: string, + value: string + ) => void) + : undefined; + const originalSetAttribute = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttribute'); + const originalSetAttributeNS = Object.getOwnPropertyDescriptor( + ctor.prototype, + 'setAttributeNS' + ); + const targetDocument = typeof document === 'undefined' ? undefined : document; + const nativeCreateElement = targetDocument?.createElement; + const originalCreateElement = targetDocument + ? Object.getOwnPropertyDescriptor(targetDocument, 'createElement') + : undefined; + let active = true; + let sequence = 0; + let observer: MutationObserver | undefined; + let scheduler: MutationScheduler | undefined; + let installedSource: PropertyDescriptor | undefined; + let installedSetAttribute: PropertyDescriptor | undefined; + let installedSetAttributeNS: PropertyDescriptor | undefined; + let installedCreateElement: PropertyDescriptor | undefined; + let factoryTarget: Record | undefined; + let factoryOriginal: PropertyDescriptor | undefined; + let installedFactory: unknown; + + const restore = ( + target: object, + key: PropertyKey, + owned: PropertyDescriptor | undefined, + original: PropertyDescriptor | undefined + ): void => { + try { + if (!sameDescriptor(Object.getOwnPropertyDescriptor(target, key), owned)) return; + if (original) Object.defineProperty(target, key, original); + else Reflect.deleteProperty(target, key); + } catch (error) { + log.debug(`${options.logPrefix}: failed to restore ${String(key)}`, error); + } + }; - const requestId = ++sequence; - assignments.set(element, { raw, requestId }); + const apply = (element: E, value: string): void => { + try { + nativeSet.call(element, value); + } catch (error) { + try { + nativeSetAttribute.call(element, attr, value); + } catch (fallbackError) { + log.debug( + `${options.logPrefix}: failed to apply ${options.resourceName} ${attr}`, + error, + fallbackError + ); + } + } + }; - const proxyable = options.shouldProxy(raw, element); - if (!proxyable || typeof fetch !== 'function') { - log.info(`${options.logPrefix}: skipping proxy for ${attr}`, { - reason: proxyable ? 'no-fetch' : 'non-proxyable', - raw, - }); - assignments.delete(element); - apply(element, raw); - return; - } + const proxyAssignment = (element: E, rawInput: string): void => { + if (!active) { + apply(element, String(rawInput ?? '')); + return; + } + const raw = String(rawInput || ''); + const last = lastProcessed.get(element); + if (last === raw) return; + lastProcessed.set(element, raw); - log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw }); - void options - .signProxy(raw, element) - .then((signed) => { - const current = assignments.get(element); - if (!current || current.requestId !== requestId) return; + const requestId = ++sequence; + assignments.set(element, { raw, requestId }); + + let proxyable = false; + try { + proxyable = options.shouldProxy(raw, element); + } catch (error) { + log.warn(`${options.logPrefix}: ${options.resourceName} policy failed`, error); + } + if (!proxyable || typeof fetch !== 'function') { + log.info(`${options.logPrefix}: skipping proxy for ${attr}`, { + reason: proxyable ? 'no-fetch' : 'non-proxyable', + raw, + }); assignments.delete(element); - const finalUrl = signed || raw; - if (signed) { - log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, { - base: raw, - finalUrl, - }); - } - lastProcessed.set(element, finalUrl); - apply(element, finalUrl); - }) - .catch((err) => { - const current = assignments.get(element); - if (!current || current.requestId !== requestId) return; + apply(element, raw); + return; + } + + log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw }); + let signing: Promise; + try { + signing = options.signProxy(raw, element); + } catch (error) { assignments.delete(element); log.warn( `${options.logPrefix}: failed to proxy dynamic ${options.resourceName}; using raw ${attr}`, - err + error ); - lastProcessed.set(element, raw); apply(element, raw); - }); - } - - function monitorMutations(ctor: ElementCtor): void { - if (observerInstalled) return; - if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return; - - const schedule = createMutationScheduler((element) => { - ensureInstancePatched(element); - const fromAttr = element.getAttribute(attr) || ''; - const liveValue = (element as unknown as { [key: string]: string | undefined })[attr] || ''; - const raw = fromAttr || liveValue; - if (!raw) return; - log.info(`${options.logPrefix}: observed ${attr} set`, { raw }); - proxyAssignment(element, raw); - }); - - const scan = () => { - document.querySelectorAll(options.selector).forEach((el) => { - schedule(el as E); - }); - }; - - log.info(`${options.logPrefix}: initial ${options.resourceName} scan`); - scan(); - - const observer = new MutationObserver((records) => { - for (const record of records) { - if (record.type === 'attributes') { - const target = record.target; - if (target instanceof ctor && record.attributeName === attr) { - schedule(target as E); + return; + } + void signing + .then((signed) => { + if (!active) return; + const current = assignments.get(element); + if (!current || current.requestId !== requestId) return; + assignments.delete(element); + const finalUrl = signed || raw; + if (signed) { + log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, { + base: raw, + finalUrl, + }); } - continue; - } + lastProcessed.set(element, finalUrl); + apply(element, finalUrl); + }) + .catch((error) => { + if (!active) return; + const current = assignments.get(element); + if (!current || current.requestId !== requestId) return; + assignments.delete(element); + log.warn( + `${options.logPrefix}: failed to proxy dynamic ${options.resourceName}; using raw ${attr}`, + error + ); + lastProcessed.set(element, raw); + apply(element, raw); + }); + }; - if (record.type === 'childList') { - record.addedNodes.forEach((node) => { - if (node instanceof ctor) { - schedule(node as E); + const ensureInstancePatched = (element: E | null | undefined): void => { + if (!active || !element || instancePatches.has(element)) return; + const original = Object.getOwnPropertyDescriptor(element, attr); + try { + Object.defineProperty(element, attr, { + configurable: true, + enumerable: true, + get(this: E) { + const pending = assignments.get(this); + if (pending) return pending.raw; + return nativeGet ? nativeGet.call(this) : ''; + }, + set(this: E, value: string) { + if (!active) { + apply(this, String(value ?? '')); return; } - if (!(node instanceof Element)) return; - node.querySelectorAll(options.selector).forEach((el) => schedule(el as E)); - }); - } + log.info(`${options.logPrefix}: ${tagName} instance ${attr} set`, value); + proxyAssignment(this, String(value ?? '')); + }, + }); + const installed = Object.getOwnPropertyDescriptor(element, attr); + if (installed) instancePatches.set(element, { installed, original }); + } catch (error) { + log.debug(`${options.logPrefix}: failed to patch ${tagName} instance ${attr}`, error); } - }); - - observer.observe(document, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: [attr], - }); - - observerInstalled = true; - log.info(`${options.logPrefix}: mutation observer active`); - } + }; - function ensureInstancePatched(element: E | null | undefined): void { - if (!element || wrappedInstances.has(element)) return; - wrappedInstances.add(element); - try { - Object.defineProperty(element, attr, { - configurable: true, - enumerable: true, - get(this: E) { - const pending = assignments.get(this); - if (pending) return pending.raw; - return nativeGet ? nativeGet.call(this) : ''; - }, - set(this: E, value: string) { - log.info(`${options.logPrefix}: ${tagName} instance ${attr} set`, value); - proxyAssignment(this, String(value ?? '')); - }, + const scan = (): void => { + if (!active || !targetDocument || !scheduler) return; + targetDocument.querySelectorAll(options.selector).forEach((element) => { + scheduler?.(element as E); }); - } catch (err) { - log.debug(`${options.logPrefix}: failed to patch ${tagName} instance ${attr}`, err); - } - } + }; - function patchDocumentCreateElement(): void { - if (createElementPatched || typeof document === 'undefined' || !nativeCreateElement) return; - createElementPatched = true; - document.createElement = function patchedCreateElement( - this: Document, - name: string, - options?: ElementCreationOptions - ): HTMLElement { - const el = nativeCreateElement(name, options); - if (typeof name === 'string' && name.toLowerCase() === tagName) { - ensureInstancePatched(el as unknown as E); + const dispose = (): void => { + if (!active) return; + active = false; + observer?.disconnect(); + scheduler?.dispose(); + for (const [element, patch] of instancePatches) { + restore(element, attr, patch.installed, patch.original); } - return el; - } as typeof document.createElement; - } - - function patchFactory(): void { - if (!options.factoryName || factoryPatched) return; - const globalObj = globalThis as Record; - const factory = globalObj[options.factoryName]; - if (typeof factory !== 'function') return; - const factoryFn = factory as FactoryFunction; - - const WrappedFactory = function (this: unknown, ...args: unknown[]) { - const instance = Reflect.construct(factoryFn, args, new.target ?? WrappedFactory) as E; - ensureInstancePatched(instance); - return instance; + instancePatches.clear(); + if (targetDocument) { + restore(targetDocument, 'createElement', installedCreateElement, originalCreateElement); + } + if ( + factoryTarget && + options.factoryName && + factoryTarget[options.factoryName] === installedFactory + ) { + try { + if (factoryOriginal) { + Object.defineProperty(factoryTarget, options.factoryName, factoryOriginal); + } else { + Reflect.deleteProperty(factoryTarget, options.factoryName); + } + } catch (error) { + log.debug(`${options.logPrefix}: failed to restore ${options.factoryName}`, error); + } + } + restore(ctor.prototype, 'setAttributeNS', installedSetAttributeNS, originalSetAttributeNS); + restore(ctor.prototype, 'setAttribute', installedSetAttribute, originalSetAttribute); + restore(ctor.prototype, attr, installedSource, sourceDescriptor); }; - Object.defineProperty(WrappedFactory, 'length', { - value: factoryFn.length, - configurable: true, - }); - Object.defineProperty(WrappedFactory, 'name', { - value: options.factoryName, - configurable: true, - }); - WrappedFactory.prototype = factoryFn.prototype; - Object.setPrototypeOf(WrappedFactory, factoryFn); - - globalObj[options.factoryName] = WrappedFactory as unknown; - factoryPatched = true; - } - - return function install(): void { - if (proxyInstalled) return; - const ctor = options.elementConstructor; - if (typeof ctor !== 'function') return; - - log.info(`${options.logPrefix}: installing dynamic ${options.resourceName} proxy hooks`); - - const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, attr); - if (!descriptor || typeof descriptor.set !== 'function') { - log.debug(`${options.logPrefix}: ${ctor.name} proxy install skipped (no setter)`); - return; - } - - nativeSet = descriptor.set as typeof nativeSet; - nativeGet = - typeof descriptor.get === 'function' ? (descriptor.get as typeof nativeGet) : undefined; - nativeSetAttribute = ctor.prototype.setAttribute as typeof nativeSetAttribute; - nativeSetAttributeNS = - typeof ctor.prototype.setAttributeNS === 'function' - ? (ctor.prototype.setAttributeNS as typeof nativeSetAttributeNS) - : undefined; + const handle = Object.freeze({ dispose, scan }); - let prototypePatched = false; - if (descriptor.configurable !== false) { - try { + try { + log.info(`${options.logPrefix}: installing dynamic ${options.resourceName} proxy hooks`); + let prototypePatched = false; + if (sourceDescriptor.configurable !== false) { Object.defineProperty(ctor.prototype, attr, { configurable: true, - enumerable: descriptor.enumerable ?? true, + enumerable: sourceDescriptor.enumerable ?? true, get(this: E) { - log.info(`${options.logPrefix}: ${ctor.name} ${attr} get`); const pending = assignments.get(this); if (pending) return pending.raw; return nativeGet ? nativeGet.call(this) : ''; }, set(this: E, value: string) { + if (!active) { + apply(this, String(value ?? '')); + return; + } log.info(`${options.logPrefix}: ${ctor.name} ${attr} set`, value); proxyAssignment(this, String(value ?? '')); }, }); + installedSource = Object.getOwnPropertyDescriptor(ctor.prototype, attr); prototypePatched = true; - } catch (err) { - log.debug(`${options.logPrefix}: failed to patch prototype ${attr}`, err); - } - } else { - log.debug(`${options.logPrefix}: prototype ${attr} not configurable; using fallback`); - } - - ctor.prototype.setAttribute = function patchedSetAttribute( - this: E, - name: string, - value: string - ) { - log.debug(`${options.logPrefix}: ${ctor.name} setAttribute`, { name, value }); - if (typeof name === 'string' && name.toLowerCase() === attr) { - proxyAssignment(this, String(value ?? '')); - return; + } else { + log.debug(`${options.logPrefix}: prototype ${attr} not configurable; using fallback`); } - nativeSetAttribute.call(this, name, value); - }; - if (nativeSetAttributeNS) { - ctor.prototype.setAttributeNS = function patchedSetAttributeNS( + ctor.prototype.setAttribute = function patchedSetAttribute( this: E, - namespace: string | null, name: string, value: string ): void { - log.debug(`${options.logPrefix}: ${ctor.name} setAttributeNS`, { namespace, name, value }); - if (typeof name === 'string' && name.toLowerCase() === attr) { - proxyAssignment(this, String(value ?? '')); + if (!active || typeof name !== 'string' || name.toLowerCase() !== attr) { + nativeSetAttribute.call(this, name, value); return; } - nativeSetAttributeNS!.call(this, namespace, name, value); + log.debug(`${options.logPrefix}: ${ctor.name} setAttribute`, { name, value }); + proxyAssignment(this, String(value ?? '')); }; - } + installedSetAttribute = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttribute'); + + if (nativeSetAttributeNS) { + ctor.prototype.setAttributeNS = function patchedSetAttributeNS( + this: E, + namespace: string | null, + name: string, + value: string + ): void { + if (!active || typeof name !== 'string' || name.toLowerCase() !== attr) { + nativeSetAttributeNS.call(this, namespace, name, value); + return; + } + log.debug(`${options.logPrefix}: ${ctor.name} setAttributeNS`, { + namespace, + name, + value, + }); + proxyAssignment(this, String(value ?? '')); + }; + installedSetAttributeNS = Object.getOwnPropertyDescriptor(ctor.prototype, 'setAttributeNS'); + } - proxyInstalled = true; - log.info(`${options.logPrefix}: dynamic ${options.resourceName} proxy installed`); + if (!prototypePatched) { + if (targetDocument && nativeCreateElement) { + targetDocument + .querySelectorAll(options.selector) + .forEach((element) => ensureInstancePatched(element as E)); + targetDocument.createElement = function patchedCreateElement( + this: Document, + name: string, + creationOptions?: ElementCreationOptions + ): HTMLElement { + const element = nativeCreateElement.call(this, name, creationOptions); + if (active && typeof name === 'string' && name.toLowerCase() === tagName) { + ensureInstancePatched(element as unknown as E); + } + return element; + } as typeof targetDocument.createElement; + installedCreateElement = Object.getOwnPropertyDescriptor(targetDocument, 'createElement'); + } - if (!prototypePatched) { - log.info(`${options.logPrefix}: using instance-level proxy fallback`); - if (typeof document !== 'undefined') { - document.querySelectorAll(options.selector).forEach((el) => ensureInstancePatched(el as E)); + if (options.factoryName) { + const globalObject = globalThis as Record; + const factory = globalObject[options.factoryName]; + if (typeof factory === 'function') { + const factoryFunction = factory as FactoryFunction; + factoryTarget = globalObject; + factoryOriginal = Object.getOwnPropertyDescriptor(globalObject, options.factoryName); + const WrappedFactory = function (this: unknown, ...args: unknown[]) { + const instance = Reflect.construct( + factoryFunction, + args, + new.target ?? WrappedFactory + ) as E; + if (active) ensureInstancePatched(instance); + return instance; + }; + Object.defineProperty(WrappedFactory, 'length', { + value: factoryFunction.length, + configurable: true, + }); + Object.defineProperty(WrappedFactory, 'name', { + value: options.factoryName, + configurable: true, + }); + WrappedFactory.prototype = factoryFunction.prototype; + Object.setPrototypeOf(WrappedFactory, factoryFunction); + globalObject[options.factoryName] = WrappedFactory; + installedFactory = WrappedFactory; + } + } } - patchDocumentCreateElement(); - patchFactory(); - } - monitorMutations(ctor); + if (targetDocument && typeof MutationObserver !== 'undefined') { + scheduler = createMutationScheduler((element) => { + if (!active) return; + ensureInstancePatched(element); + const fromAttribute = element.getAttribute(attr) || ''; + const liveValue = + (element as unknown as { [key: string]: string | undefined })[attr] || ''; + const raw = fromAttribute || liveValue; + if (!raw) return; + log.info(`${options.logPrefix}: observed ${attr} set`, { raw }); + proxyAssignment(element, raw); + }); + observer = new MutationObserver((records) => { + if (!active) return; + for (const record of records) { + if (record.type === 'attributes') { + const target = record.target; + if (target instanceof ctor && record.attributeName === attr) scheduler?.(target as E); + continue; + } + if (record.type !== 'childList') continue; + record.addedNodes.forEach((node) => { + if (node instanceof ctor) { + scheduler?.(node as E); + return; + } + if (!(node instanceof Element)) return; + node + .querySelectorAll(options.selector) + .forEach((element) => scheduler?.(element as E)); + }); + } + }); + observer.observe(targetDocument, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: [attr], + }); + } + + installedHandle = handle; + if (scanInitially) scan(); + return handle; + } catch (error) { + dispose(); + throw error; + } }; } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts b/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts index 24c003373..a23d1b19f 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/iframe.ts @@ -1,6 +1,7 @@ // Dynamic iframe proxy guard: routes iframe src assignments through the first-party proxy. import { createDynamicSrcProxy } from './dynamic_src_guard'; import { shouldProxyExternalUrl, signProxyUrl } from './proxy_sign'; +import type { CreativeGuardHandle } from './startup'; const installProxy = createDynamicSrcProxy({ elementConstructor: typeof HTMLIFrameElement === 'undefined' ? undefined : HTMLIFrameElement, @@ -12,6 +13,6 @@ const installProxy = createDynamicSrcProxy({ signProxy: (raw) => signProxyUrl(raw), }); -export function installDynamicIframeProxy(): void { - installProxy(); +export function installDynamicIframeProxy(scanInitially = true): CreativeGuardHandle { + return installProxy(scanInitially); } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/image.ts b/crates/trusted-server-js/lib/src/integrations/creative/image.ts index dc608fc32..d64a62c95 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/image.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/image.ts @@ -1,6 +1,7 @@ // Dynamic image proxy guard: intercepts sources and routes them via first-party proxy. import { createDynamicSrcProxy } from './dynamic_src_guard'; import { shouldProxyExternalUrl, signProxyUrl } from './proxy_sign'; +import type { CreativeGuardHandle } from './startup'; // NOTE: This module intentionally logs at info level in the hot paths so that when // creatives crash before reaching a console, we still have breadcrumbs showing how @@ -20,6 +21,6 @@ const installProxy = createDynamicSrcProxy({ }); // Prepare global hooks so every img.src assignment flows through Trusted Server first. -export function installDynamicImageProxy(): void { - installProxy(); +export function installDynamicImageProxy(scanInitially = true): CreativeGuardHandle { + return installProxy(scanInitially); } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/index.ts b/crates/trusted-server-js/lib/src/integrations/creative/index.ts index 395553562..e3589e496 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -6,6 +6,7 @@ import { creativeGlobal, resolveWindow } from '../../shared/globals'; import { installClickGuard } from './click'; import { installDynamicImageProxy } from './image'; import { installDynamicIframeProxy } from './iframe'; +import type { CreativeGuardHandle } from './startup'; export { installDynamicImageProxy } from './image'; export { installDynamicIframeProxy } from './iframe'; @@ -19,20 +20,41 @@ let currentConfig: Required = { ...DEFAULT_CONFIG }; let guardsInstallTriggered = false; let clickGuardInstalled = false; let renderGuardInstalled = false; +let clickGuardHandle: CreativeGuardHandle | undefined; +let imageGuardHandle: CreativeGuardHandle | undefined; +let iframeGuardHandle: CreativeGuardHandle | undefined; function applyConfig(): void { if (currentConfig.clickGuard && !clickGuardInstalled) { - installClickGuard(); + clickGuardHandle = installClickGuard(); clickGuardInstalled = true; } if (currentConfig.renderGuard && !renderGuardInstalled) { - installDynamicImageProxy(); - installDynamicIframeProxy(); + imageGuardHandle = installDynamicImageProxy(); + iframeGuardHandle = installDynamicIframeProxy(); renderGuardInstalled = true; } } +/** Release only the wrappers, listeners, observers, and queued work installed by this module. */ +export function disposeGuards(): void { + const handles = [iframeGuardHandle, imageGuardHandle, clickGuardHandle]; + iframeGuardHandle = undefined; + imageGuardHandle = undefined; + clickGuardHandle = undefined; + renderGuardInstalled = false; + clickGuardInstalled = false; + guardsInstallTriggered = false; + for (let index = 0; index < handles.length; index += 1) { + try { + handles[index]?.dispose(); + } catch { + // One hostile cleanup cannot retain another guard's owned state. + } + } +} + function mergeConfig(cfg: TsCreativeConfig): void { currentConfig = { clickGuard: cfg.clickGuard ?? currentConfig.clickGuard, diff --git a/crates/trusted-server-js/lib/src/shared/scheduler.ts b/crates/trusted-server-js/lib/src/shared/scheduler.ts index 32664f635..088eadfe5 100644 --- a/crates/trusted-server-js/lib/src/shared/scheduler.ts +++ b/crates/trusted-server-js/lib/src/shared/scheduler.ts @@ -1,15 +1,34 @@ // Mutation observer helper that batches callbacks onto the microtask queue. import { queueTask } from './async'; +export interface MutationScheduler { + (target: T): void; + readonly dispose: () => void; +} + // Coalesce repeated mutation callbacks on the same element into a single microtask run. -export function createMutationScheduler(perform: (target: T) => void) { +export function createMutationScheduler( + perform: (target: T) => void +): MutationScheduler { const queued = new WeakSet(); - return (target: T) => { + let active = true; + const schedule = ((target: T): void => { + if (!active) return; if (queued.has(target)) return; queued.add(target); queueTask(() => { queued.delete(target); + if (!active) return; perform(target); }); - }; + }) as MutationScheduler; + Object.defineProperty(schedule, 'dispose', { + configurable: false, + enumerable: true, + value: (): void => { + active = false; + }, + writable: false, + }); + return Object.freeze(schedule); } diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index fae4eb407..beb9411b1 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -1,6 +1,12 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { FIRST_PARTY_CLICK, MUTATED_CLICK, PROXY_RESPONSE, importCreativeModule } from './helpers'; +import { + FIRST_PARTY_CLICK, + MUTATED_CLICK, + PROXY_RESPONSE, + disposeImportedCreativeModule, + importCreativeModule, +} from './helpers'; const ORIGINAL_FETCH = global.fetch; @@ -11,15 +17,47 @@ const REBUILD_PREFIX = absolute('/first-party/proxy-rebuild?'); describe('creative/click.ts', () => { beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; vi.useRealTimers(); }); + it('owns click listeners and defers the baseline scan until requested', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: PROXY_RESPONSE }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + document.body.appendChild(anchor); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + + const handle = installClickGuard(false); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(fetchMock).not.toHaveBeenCalled(); + + handle.scan(); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + handle.dispose(); + handle.dispose(); + expect(removeEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + expect(removeEventListener.mock.calls.filter(([type]) => type === 'auxclick')).toHaveLength(1); + }); + it('repairs anchors via proxy rebuild fallback when fetch is unavailable', async () => { vi.useFakeTimers(); global.fetch = undefined as unknown as typeof fetch; diff --git a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts index 176a5e5ab..1ce8a069c 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts @@ -19,7 +19,16 @@ export const PROXY_RESPONSE = import type { TsCreativeConfig } from '../../../src/shared/globals'; +let disposeLastImportedCreative: (() => void) | undefined; + +export function disposeImportedCreativeModule(): void { + const dispose = disposeLastImportedCreative; + disposeLastImportedCreative = undefined; + dispose?.(); +} + export async function importCreativeModule(config?: TsCreativeConfig): Promise { + disposeImportedCreativeModule(); const globalRef = globalThis as { __ts_creative_installed?: boolean; tsCreativeConfig?: TsCreativeConfig; @@ -28,7 +37,8 @@ export async function importCreativeModule(config?: TsCreativeConfig): Promise { const ORIGINAL_FETCH = global.fetch; beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; }); @@ -52,4 +54,24 @@ describe('creative/iframe.ts', () => { expect(iframe.src).toContain('https://frame.example/fallback.html'); }); }); + + it('cancels queued and future iframe rewrites on disposal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: '/first-party/proxy?tsurl=iframe&tstoken=token&tsexp=1' }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + const { installDynamicIframeProxy } = await import('../../../src/integrations/creative/iframe'); + const handle = installDynamicIframeProxy(false); + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'https://frame.example/queued.html'); + + handle.dispose(); + await Promise.resolve(); + iframe.setAttribute('src', 'https://frame.example/later.html'); + await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(iframe.src).toContain('https://frame.example/later.html'); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts index 44105571d..525bb66ad 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts @@ -1,16 +1,18 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { importCreativeModule, waitForExpect } from './helpers'; +import { disposeImportedCreativeModule, importCreativeModule, waitForExpect } from './helpers'; const ORIGINAL_FETCH = global.fetch; describe('creative/image.ts', () => { beforeEach(() => { + disposeImportedCreativeModule(); vi.resetModules(); document.body.innerHTML = ''; }); afterEach(() => { + disposeImportedCreativeModule(); global.fetch = ORIGINAL_FETCH; }); @@ -52,4 +54,30 @@ describe('creative/image.ts', () => { expect(img.src).toContain('https://img.example/fallback.png'); }); }); + + it('defers the baseline scan and restores only its exact hooks on disposal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ href: '/first-party/proxy?tsurl=image&tstoken=token&tsexp=1' }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + const image = document.createElement('img'); + image.setAttribute('src', 'https://img.example/preexisting.png'); + document.body.appendChild(image); + const baselineSrc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); + const baselineSetAttribute = HTMLImageElement.prototype.setAttribute; + const { installDynamicImageProxy } = await import('../../../src/integrations/creative/image'); + + const handle = installDynamicImageProxy(false); + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + + handle.scan(); + await waitForExpect(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + handle.dispose(); + handle.dispose(); + + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual(baselineSrc); + expect(HTMLImageElement.prototype.setAttribute).toBe(baselineSetAttribute); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts new file mode 100644 index 000000000..1629c5a20 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FIRST_PARTY_CLICK, MUTATED_CLICK, waitForExpect } from './helpers'; + +const ORIGINAL_FETCH = global.fetch; + +describe('creative guard ownership', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + vi.useRealTimers(); + }); + + it('defers the click scan and releases its observer and capture listeners', async () => { + vi.useFakeTimers(); + global.fetch = undefined as unknown as typeof fetch; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + document.body.appendChild(anchor); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + + const guard = installClickGuard(false); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(anchor.getAttribute('href')).toBe(MUTATED_CLICK); + + guard.scan(); + await Promise.resolve(); + await vi.runAllTimersAsync(); + expect(anchor.getAttribute('href')).toContain('/first-party/proxy-rebuild?'); + + guard.dispose(); + guard.dispose(); + anchor.setAttribute('href', MUTATED_CLICK); + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + anchor.dispatchEvent(click); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(click.defaultPrevented).toBe(false); + expect(anchor.getAttribute('href')).toBe(MUTATED_CLICK); + }); + + it('defers image scans, cancels late signing, and compare-restores owned hooks', async () => { + let resolveSigning: ((value: unknown) => void) | undefined; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveSigning = resolve; + }) + ); + global.fetch = fetchMock as unknown as typeof fetch; + const image = document.createElement('img'); + image.setAttribute('src', 'https://img.example/existing.gif'); + document.body.appendChild(image); + const descriptorBefore = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); + const { installDynamicImageProxy } = await import('../../../src/integrations/creative/image'); + + const guard = installDynamicImageProxy(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).not.toEqual( + descriptorBefore + ); + + guard.scan(); + await waitForExpect(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + guard.dispose(); + resolveSigning?.({ + ok: true, + json: async () => ({ href: '/first-party/proxy?late=1' }), + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(image.getAttribute('src')).toBe('https://img.example/existing.gif'); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual( + descriptorBefore + ); + }); + + it('does not overwrite a foreign iframe hook installed after activation', async () => { + const { installDynamicIframeProxy } = await import('../../../src/integrations/creative/iframe'); + const guard = installDynamicIframeProxy(false); + const owned = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'); + expect(owned).toBeDefined(); + const foreignGet = function (this: HTMLIFrameElement): string { + return this.getAttribute('src') ?? ''; + }; + const foreignSet = function (this: HTMLIFrameElement, value: string): void { + this.setAttribute('src', value); + }; + Object.defineProperty(HTMLIFrameElement.prototype, 'src', { + configurable: true, + enumerable: owned?.enumerable ?? true, + get: foreignGet, + set: foreignSet, + }); + + guard.dispose(); + + const current = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'); + expect(current?.get).toBe(foreignGet); + expect(current?.set).toBe(foreignSet); + }); +}); diff --git a/crates/trusted-server-js/lib/test/shared/scheduler.test.ts b/crates/trusted-server-js/lib/test/shared/scheduler.test.ts index aa4a21ecc..59f8a6bd9 100644 --- a/crates/trusted-server-js/lib/test/shared/scheduler.test.ts +++ b/crates/trusted-server-js/lib/test/shared/scheduler.test.ts @@ -42,4 +42,18 @@ describe('shared/scheduler', () => { await Promise.resolve(); expect(perform).toHaveBeenCalledTimes(2); }); + + it('cancels queued and future work after disposal', async () => { + const perform = vi.fn(); + const schedule = createMutationScheduler(perform); + const el = document.createElement('div'); + + schedule(el); + schedule.dispose(); + await Promise.resolve(); + schedule(el); + await Promise.resolve(); + + expect(perform).not.toHaveBeenCalled(); + }); }); From 14572b296bdf49fd4c17649f41bd95ef1daa1fc4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:13:52 -0700 Subject: [PATCH 354/494] Allow clean creative guard reactivation --- .../creative/dynamic_src_guard.ts | 21 +++++-------------- .../integrations/creative/ownership.test.ts | 10 +++++++++ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts index 386b0582c..b8152c439 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts @@ -116,7 +116,7 @@ export function createDynamicSrcProxy( let installedCreateElement: PropertyDescriptor | undefined; let factoryTarget: Record | undefined; let factoryOriginal: PropertyDescriptor | undefined; - let installedFactory: unknown; + let installedFactory: PropertyDescriptor | undefined; const restore = ( target: object, @@ -268,24 +268,13 @@ export function createDynamicSrcProxy( if (targetDocument) { restore(targetDocument, 'createElement', installedCreateElement, originalCreateElement); } - if ( - factoryTarget && - options.factoryName && - factoryTarget[options.factoryName] === installedFactory - ) { - try { - if (factoryOriginal) { - Object.defineProperty(factoryTarget, options.factoryName, factoryOriginal); - } else { - Reflect.deleteProperty(factoryTarget, options.factoryName); - } - } catch (error) { - log.debug(`${options.logPrefix}: failed to restore ${options.factoryName}`, error); - } + if (factoryTarget && options.factoryName) { + restore(factoryTarget, options.factoryName, installedFactory, factoryOriginal); } restore(ctor.prototype, 'setAttributeNS', installedSetAttributeNS, originalSetAttributeNS); restore(ctor.prototype, 'setAttribute', installedSetAttribute, originalSetAttribute); restore(ctor.prototype, attr, installedSource, sourceDescriptor); + if (installedHandle === handle) installedHandle = undefined; }; const handle = Object.freeze({ dispose, scan }); @@ -398,7 +387,7 @@ export function createDynamicSrcProxy( WrappedFactory.prototype = factoryFunction.prototype; Object.setPrototypeOf(WrappedFactory, factoryFunction); globalObject[options.factoryName] = WrappedFactory; - installedFactory = WrappedFactory; + installedFactory = Object.getOwnPropertyDescriptor(globalObject, options.factoryName); } } } diff --git a/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts index 1629c5a20..6e1b8bcb5 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/ownership.test.ts @@ -81,6 +81,16 @@ describe('creative guard ownership', () => { expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual( descriptorBefore ); + + const replacement = installDynamicImageProxy(false); + expect(replacement).not.toBe(guard); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).not.toEqual( + descriptorBefore + ); + replacement.dispose(); + expect(Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')).toEqual( + descriptorBefore + ); }); it('does not overwrite a foreign iframe hook installed after activation', async () => { From 26025df8e4b3405a6cc3ffec4b58542e0cd9ba8b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:14:39 -0700 Subject: [PATCH 355/494] Harden GPT publisher handoff ownership --- .../lib/src/adapters/googletag.ts | 53 +++- .../lib/src/composition/browser.ts | 1 + .../lib/src/integrations/gpt/startup.ts | 6 +- .../lib/src/services/slots.ts | 207 +++++++++++---- .../lib/src/services/targeting.ts | 34 ++- .../lib/test/integrations/gpt/module.test.ts | 4 +- .../lib/test/integrations/gpt/startup.test.ts | 44 +++- .../lib/test/services/slots.test.ts | 236 +++++++++++++++++- .../lib/test/services/targeting.test.ts | 98 ++++++++ 9 files changed, 606 insertions(+), 77 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 0889fb019..d4c00efd6 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -83,6 +83,12 @@ export interface GoogletagTargetingObserver { readonly beforePublisherMutation: (slot: object, key?: string) => void; } +/** Callable targeting observation release with an exact wrapper-identity latch. */ +export interface GoogletagTargetingObservation { + (): void; + readonly isCurrent: () => boolean; +} + /** One publisher-originated GPT call observed outside Trusted Server operations. */ export interface GoogletagPublisherCallObserver { readonly defineSlot?: ( @@ -131,7 +137,10 @@ export interface GoogletagFacade { clearTargeting(slot: object, key?: string): unknown; display(slot: string | object): unknown; getTargeting(slot: object, key: string): readonly string[]; - observeTargeting(slot: object, observer: GoogletagTargetingObserver): () => void; + observeTargeting( + slot: object, + observer: GoogletagTargetingObserver + ): GoogletagTargetingObservation; refresh(slots?: readonly object[], options?: Readonly<{ changeCorrelator: boolean }>): unknown; serviceState(): Readonly<{ apiReady: boolean; @@ -227,6 +236,7 @@ interface SharedInitialLoadTracker { } interface TargetingObservation { + readonly isCurrent: () => boolean; readonly observers: Set; readonly restore: () => void; } @@ -427,7 +437,7 @@ function createFacade( slot: object, key: 'clearTargeting' | 'setTargeting', observer: GoogletagTargetingObserver - ): (() => void) | undefined => { + ): Readonly<{ isCurrent: () => boolean; restore: () => void }> | undefined => { if (!isOperationCurrent()) return undefined; const original = member(slot, key); let descriptor: PropertyDescriptor | undefined; @@ -455,6 +465,15 @@ function createFacade( // Publisher replacement wins once the installed method no longer matches. } }; + const wrapperIsCurrent = (): boolean => { + try { + if (!defineAttempted) return false; + const current = Object.getOwnPropertyDescriptor(slot, key); + return current !== undefined && current.value === wrapper; + } catch { + return false; + } + }; try { descriptor = Object.getOwnPropertyDescriptor(slot, key); if ( @@ -479,7 +498,7 @@ function createFacade( restore(); return undefined; } - return restore; + return Object.freeze({ isCurrent: wrapperIsCurrent, restore }); } catch { restore(); return undefined; @@ -506,7 +525,10 @@ function createFacade( if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return Object.freeze([...targeting]); }, - observeTargeting: (slot: object, observer: GoogletagTargetingObserver): (() => void) => { + observeTargeting: ( + slot: object, + observer: GoogletagTargetingObserver + ): GoogletagTargetingObservation => { if ( typeof observer !== 'object' || observer === null || @@ -535,19 +557,20 @@ function createFacade( if (!restoreSet) throw new GoogletagAdapterError('external_artifact_incompatible'); const restoreClear = replaceObservedMethod(slot, 'clearTargeting', dispatcher); if (!restoreClear) { - restoreSet(); + restoreSet.restore(); throw new GoogletagAdapterError('external_artifact_incompatible'); } let restored = false; observation = { + isCurrent: (): boolean => restoreSet.isCurrent() && restoreClear.isCurrent(), observers, restore: (): void => { if (restored) return; restored = true; try { - restoreClear(); + restoreClear.restore(); } finally { - restoreSet(); + restoreSet.restore(); } }, }; @@ -570,7 +593,7 @@ function createFacade( throw error; } let active = true; - return registerEffect(() => { + const releaseEffect = registerEffect(() => { if (!active) return; active = false; deleteSetValue(observation!.observers, observer); @@ -581,6 +604,20 @@ function createFacade( observation!.restore(); } }); + const release = (() => releaseEffect()) as GoogletagTargetingObservation; + Object.defineProperty(release, 'isCurrent', { + configurable: false, + enumerable: true, + value: (): boolean => { + try { + return active && observation?.isCurrent() === true; + } catch { + return false; + } + }, + writable: false, + }); + return Object.freeze(release); }, refresh: ( slots?: readonly object[], diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 8975b00d1..de40a8133 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -578,6 +578,7 @@ export function createTestBrowserRuntimeComposition( }, googletag: composition.adapters.googletag, ...(reconciliation ? { reconciliation } : {}), + warnPublisherHandoffMismatch: (message, details) => log.warn(message, details), }); const targetingService = createTargetingService(); const reservationService = createReservationService({ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts index d0f92278b..0a63d88ad 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts @@ -14,6 +14,7 @@ type GptPublisherSlotBoundary = Pick< | 'preparePublisherDisplay' | 'preparePublisherRefresh' | 'recordPublisherDestruction' + | 'start' >; export interface GptStartup { @@ -48,6 +49,9 @@ export function createGptStartup(options: GptStartupOptions): GptStartup { }); return options.googletag.observePublisherCalls(observer); }, - start: (config: unknown): void => options.start?.(config), + start: (config: unknown): void => { + options.slots().start(); + options.start?.(config); + }, }); } diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index c0ee6b796..e9a348eff 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -10,6 +10,7 @@ import type { GoogletagReplacementResult, } from '../adapters/googletag'; import { + GoogletagAdapterError, GoogletagReplacementCandidateCollisionError, GoogletagReplacementError, } from '../adapters/googletag'; @@ -84,6 +85,8 @@ export type GptSlotAdoptionResult = Readonly< export type SlotRequestFailure = | 'cycle_unattributable' + | 'external_queue_full' + | 'external_ready_timeout' | 'gpt_completion_timeout' | 'gpt_request_failed' | 'gpt_request_timeout' @@ -130,7 +133,7 @@ export interface SlotServiceInventory { /** Runtime-owned slot registry and physical-cycle boundary. */ export interface SlotService { - readonly activate: () => GoogletagOperation; + readonly activate: () => void; readonly adoptGptSlot: ( navigationGeneration: object, registeredSlotId: string, @@ -170,6 +173,7 @@ export interface SlotService { readonly request: (input: SlotRequestInput) => SlotRequestHandle; readonly requestBatch: (inputs: readonly SlotBatchRequestInput[]) => readonly SlotRequestHandle[]; readonly snapshotRegisteredSlots: (owner: NavigationSession) => readonly SlotRecord[] | undefined; + readonly start: () => GoogletagOperation; readonly resolveAdUnitCode: (adUnitCode: string) => SlotRecord | undefined; readonly resolveDomAlias: (alias: string) => SlotRecord | undefined; readonly resolveRegisteredSlot: (registeredSlotId: string) => SlotRecord | undefined; @@ -184,6 +188,10 @@ export interface SlotServiceOptions { readonly googletag: GoogletagAdapter; readonly now?: () => number; readonly reconciliation?: SlotReconciliationBoundary; + readonly warnPublisherHandoffMismatch?: ( + message: string, + details: Readonly<{ formatsMismatch: boolean; pathMismatch: boolean }> + ) => void; } export type SlotReconciliationResolution = @@ -250,6 +258,7 @@ interface ReconciliationWindow { firstPassFinished: boolean; operation: GoogletagOperation | undefined; readonly orphan: PhysicalSlot; + pendingFailureReason: SlotRequestFailure | undefined; terminal: boolean; } @@ -569,6 +578,15 @@ const failed = (reason: SlotRequestFailure): SlotRequestOutcome => const cancelled = (reason: 'navigation_disposed' | 'superseded'): SlotRequestOutcome => Object.freeze({ status: 'cancelled' as const, reason }); +function externalInvocationFailure(error: unknown): SlotRequestFailure { + if (error instanceof GoogletagAdapterError) { + if (error.code === 'external_queue_full' || error.code === 'external_ready_timeout') { + return error.code; + } + } + return 'gpt_request_failed'; +} + function placementKeysFor( registeredSlotId: string, adUnitCode: string | undefined, @@ -1104,6 +1122,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const cancelIntent = (intent: RequestIntent): void => { if (intent.terminal) return; + if (intent.record.reconciliation?.pendingFailureReason !== undefined) { + settle(intent, cancelled('superseded')); + return; + } const wasInvoked = intent.requestStartedAt !== undefined; const physical = intent.record.physical; if (intent.state === 'cycle' && physical?.activeCycle?.intent === intent) { @@ -1168,18 +1190,24 @@ export function createSlotService(options: SlotServiceOptions): SlotService { intent.requestTimer = setTimeout(() => onRequestTimeout(intent), GPT_REQUEST_START_TIMEOUT_MS); }; - const failExternalInvocation = (record: InternalSlotRecord, intent: RequestIntent): void => { + const failExternalInvocation = ( + record: InternalSlotRecord, + intent: RequestIntent, + error: unknown + ): void => { if (intent.terminal) return; + if (record.reconciliation?.pendingFailureReason !== undefined) return; + const reason = externalInvocationFailure(error); const physical = record.physical; if (physical?.activeCycle?.intent === intent) { physical.activeCycle.intent = undefined; physical.state = 'quarantined'; physical.quarantineReason = 'completion'; - settle(intent, failed('gpt_request_failed')); + settle(intent, failed(reason)); return; } const wasInvoked = intent.requestStartedAt !== undefined; - settle(intent, failed('gpt_request_failed')); + settle(intent, failed(reason)); if (wasInvoked && physical) recoverRequestTimeout(record, physical); else advanceQueued(record); }; @@ -1243,6 +1271,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { intent: RequestIntent, expectedBindingToken: object ): void { + if (record.reconciliation?.pendingFailureReason !== undefined) return; if ( intent.terminal || record.activeIntent !== intent || @@ -1296,12 +1325,12 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (intent.terminal) operation.dispose(); void operation.result.then( () => undefined, - () => { - failExternalInvocation(record, intent); + (error: unknown) => { + failExternalInvocation(record, intent, error); } ); - } catch { - failExternalInvocation(record, intent); + } catch (error) { + failExternalInvocation(record, intent, error); } } @@ -1314,9 +1343,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { provisionalSubscriptions = ensureBindingSubscriptions(gpt); return provisionalSubscriptions; }); - } catch { + } catch (error) { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); - failExternalInvocation(record, intent); + failExternalInvocation(record, intent, error); return; } intent.invocation = operation; @@ -1325,12 +1354,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (intent.invocation === operation) intent.invocation = undefined; retireHistoricalSubscriptions(subscriptions.ownership); if (intent.terminal) return; + if (record.reconciliation?.pendingFailureReason !== undefined) return; invokeExternalIntent(record, intent, subscriptions.ownership.token); }, - () => { + (error: unknown) => { if (intent.invocation === operation) intent.invocation = undefined; if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); - failExternalInvocation(record, intent); + failExternalInvocation(record, intent, error); } ); } @@ -1509,25 +1539,33 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.activeCycle = undefined; }; - const retireFailedReconciliation = ( + const pauseReconciliationIntent = (intent: RequestIntent | undefined): void => { + if (!intent || intent.terminal) return; + if (intent.requestTimer !== undefined) clearTimeout(intent.requestTimer); + if (intent.completionTimer !== undefined) clearTimeout(intent.completionTimer); + intent.requestTimer = undefined; + intent.completionTimer = undefined; + intent.requestDeadlineAt = undefined; + intent.completionDeadlineAt = undefined; + }; + + const finishFailedReconciliation = ( record: InternalSlotRecord, window: ReconciliationWindow, reason: SlotRequestFailure, - transactionStarted: boolean, oldSlotDestroyed: boolean ): void => { - if (window.terminal) return; + if (window.terminal || record.reconciliation !== window) return; window.terminal = true; clearReconciliationTimers(window); window.operation?.dispose(); window.operation = undefined; - if (record.reconciliation === window) record.reconciliation = undefined; + record.reconciliation = undefined; const physical = window.orphan; - if (record.physical !== physical || physical.ownership !== 'trusted_server') return; settleReconciliationWork(record, physical, reason); retireCommittedArtifact(record, physical); - record.physical = undefined; + if (record.physical === physical) record.physical = undefined; physical.record = undefined; physical.state = 'retired'; physical.quarantineReason = 'request'; @@ -1538,9 +1576,43 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } quarantinePhysicalPlacement(physical); - if (transactionStarted) return; + }; - let destroyOperation: GoogletagOperation | undefined; + const retireFailedReconciliation = ( + record: InternalSlotRecord, + window: ReconciliationWindow, + reason: SlotRequestFailure, + transactionStarted: boolean, + oldSlotDestroyed: boolean + ): void => { + if (window.terminal || record.reconciliation !== window) return; + if (window.pendingFailureReason !== undefined) return; + if (transactionStarted || oldSlotDestroyed) { + finishFailedReconciliation(record, window, reason, oldSlotDestroyed); + return; + } + const physical = window.orphan; + if (record.physical !== physical || physical.ownership !== 'trusted_server') { + cancelReconciliation(record); + return; + } + + window.pendingFailureReason = reason; + clearReconciliationTimers(window); + window.operation?.dispose(); + window.operation = undefined; + pauseReconciliationIntent(physical.activeCycle?.intent); + pauseReconciliationIntent(record.activeIntent); + pauseReconciliationIntent(record.queuedIntent); + physical.activeCycle = undefined; + retireCommittedArtifact(record, physical); + physical.state = 'retired'; + physical.quarantineReason = 'request'; + physical.destroyAttempted = true; + quarantinePhysicalPlacement(physical); + deleteSetValue(physicalSlots, physical); + + let destroyOperation: GoogletagOperation | undefined; try { destroyOperation = options.googletag.run((gpt) => gpt.transactionalReplace( @@ -1552,12 +1624,28 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } ) ); + window.operation = destroyOperation; void destroyOperation.result.then( - () => detachDestroyedReconciliationPhysical(physical), - () => undefined + (result) => { + if (result.status === 'destroyed') { + finishFailedReconciliation(record, window, reason, true); + return; + } + finishFailedReconciliation(record, window, 'gpt_request_failed', true); + }, + (error: unknown) => { + const replacementError = error instanceof GoogletagReplacementError ? error : undefined; + const reusedOldIdentity = replacementError?.orphanedSlot === physical.slot; + const destroyed = + replacementError?.oldSlotDestroyed === true && + replacementError.preserveOldQuarantine !== true && + !reusedOldIdentity; + finishFailedReconciliation(record, window, 'gpt_request_failed', destroyed); + } ); } catch { destroyOperation?.dispose(); + finishFailedReconciliation(record, window, 'gpt_request_failed', false); } }; @@ -1745,6 +1833,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { firstPassFinished: true, operation: undefined, orphan: physical, + pendingFailureReason: undefined, terminal: false, }; record.reconciliation = instant; @@ -1760,6 +1849,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { firstPassFinished: false, operation: undefined, orphan: physical, + pendingFailureReason: undefined, terminal: false, }; record.reconciliation = window; @@ -2491,10 +2581,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); }); - } catch { + } catch (error) { for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } return; } @@ -2521,27 +2611,27 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (remaining === 0) operation.dispose(); void operation.result.then( () => undefined, - () => { + (error: unknown) => { for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } } ); }, - () => { + (error: unknown) => { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } } ); - } catch { + } catch (error) { if (provisionalSubscriptions?.installed) provisionalSubscriptions.ownership.release(); for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (intent) failExternalInvocation(intent.record, intent); + if (intent) failExternalInvocation(intent.record, intent, error); } } } @@ -2681,6 +2771,22 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!physical || !record || !record.state.owner.isCurrent()) { return Object.freeze({ action: 'forward' }); } + if (exact.length === 1) { + const definition = physical.definition; + const formatsMismatch = + definition !== undefined && !replacementSizesEqual(sizes, definition.sizes); + const pathMismatch = definition !== undefined && adUnitPath !== definition.adUnitPath; + if (formatsMismatch || pathMismatch) { + try { + options.warnPublisherHandoffMismatch?.( + 'GPT publisher handoff metadata mismatch', + Object.freeze({ formatsMismatch, pathMismatch }) + ); + } catch { + // Diagnostics cannot block an exact publisher ownership handoff. + } + } + } const definitionElementId = physical.definition?.elementId; const aliases = definitionElementId === undefined || definitionElementId === elementId @@ -2756,30 +2862,31 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const service: SlotService = Object.freeze({ - activate: (): GoogletagOperation => { - if (activation) return activation; - if (!reconciliationActive) { - const states = mapValueSnapshot(navigationStates); - const installed: NavigationState[] = []; - for (let index = 0; index < states.length; index += 1) { - const state = states[index]; - if (!state || !installNavigationObserver(state)) { - for (let releaseIndex = installed.length - 1; releaseIndex >= 0; releaseIndex -= 1) { - const installedState = installed[releaseIndex]; - const release = installedState?.observerRelease; - if (installedState) installedState.observerRelease = undefined; - try { - release?.(); - } catch { - // Failed activation retains no observer ownership. - } + activate: (): void => { + if (reconciliationActive) return; + const states = mapValueSnapshot(navigationStates); + const installed: NavigationState[] = []; + for (let index = 0; index < states.length; index += 1) { + const state = states[index]; + if (!state || !installNavigationObserver(state)) { + for (let releaseIndex = installed.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + const installedState = installed[releaseIndex]; + const release = installedState?.observerRelease; + if (installedState) installedState.observerRelease = undefined; + try { + release?.(); + } catch { + // Failed activation retains no observer ownership. } - throw new Error('reconciliation observer failed'); } - if (state.observerRelease) installed[installed.length] = state; + throw new Error('reconciliation observer failed'); } - reconciliationActive = true; + if (state.observerRelease) installed[installed.length] = state; } + reconciliationActive = true; + }, + start: (): GoogletagOperation => { + if (activation) return activation; let subscriptions: BindingSubscriptionAdmission | undefined; const operation = options.googletag.run((gpt) => { if (disposed) return; diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index d61d86205..74c795529 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -49,6 +49,7 @@ interface TargetingFrame { readonly installed: string; readonly key: string; readonly ownerId: string; + readonly observation: GoogletagTargetingObservation | undefined; readonly slot: object; } @@ -137,6 +138,7 @@ function copyValues(values: readonly string[]): readonly string[] { /** Construct the runtime-owned GPT targeting restoration journal. */ export function createTargetingService(): TargetingService { const chainsBySlot = new WeakMap>(); + const observationsBySlot = new WeakMap(); const liveFrames = new Set(); const observationReleases = new Set<() => void>(); const setAddIntrinsic = Set.prototype.add; @@ -233,6 +235,14 @@ export function createTargetingService(): TargetingService { } }; + const observationIsCurrent = (observation: GoogletagTargetingObservation): boolean => { + try { + return observation.isCurrent() === true; + } catch { + return false; + } + }; + const release = (frame: TargetingFrame): boolean => { if (!frame.alive) return true; const slotChains = weakMapValue(chainsBySlot, frame.slot); @@ -257,6 +267,11 @@ export function createTargetingService(): TargetingService { return true; } + if (frame.observation && !observationIsCurrent(frame.observation)) { + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; + } + const wasTop = frameIndex === chain.frames.length - 1; if (!wasTop) { removeFrame(frame, slotChains, chain, frameIndex); @@ -358,6 +373,11 @@ export function createTargetingService(): TargetingService { } const actual = copyValues(targeting.getTargeting(key)); + const observation = weakMapValue(observationsBySlot, slot); + if (observation && !observationIsCurrent(observation)) { + invalidatePublisherMutation(slot); + return undefined; + } let slotChains = weakMapValue(chainsBySlot, slot); let chain = slotChains ? mapValue(slotChains, key) : undefined; const top = chain?.frames[chain.frames.length - 1]; @@ -381,6 +401,7 @@ export function createTargetingService(): TargetingService { boundary: targeting, installed: value, key, + observation, ownerId, slot, }; @@ -476,7 +497,7 @@ export function createTargetingService(): TargetingService { let ownedRelease = (): void => undefined; const operation = adapter.run((gpt) => { if (disposed) return; - let release = gpt.observeTargeting( + const release = gpt.observeTargeting( slot, Object.freeze({ beforePublisherMutation: (mutatedSlot: object, key?: string) => { @@ -489,11 +510,14 @@ export function createTargetingService(): TargetingService { if (!active) return; active = false; deleteObservationRelease(ownedRelease); + if (weakMapValue(observationsBySlot, slot) === release) { + deleteWeakMapValue(observationsBySlot, slot); + } const current = release; - release = (): void => undefined; current(); }; try { + setWeakMapValue(observationsBySlot, slot, release); addObservationRelease(ownedRelease); } catch (error) { ownedRelease(); @@ -519,4 +543,8 @@ export function createTargetingService(): TargetingService { snapshotForTest: () => Object.freeze({ frames: frameCount, slots: slotCount }), }); } -import type { GoogletagAdapter, GoogletagOperation } from '../adapters/googletag'; +import type { + GoogletagAdapter, + GoogletagOperation, + GoogletagTargetingObservation, +} from '../adapters/googletag'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 4f9e6b0ce..57fed654c 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -474,6 +474,8 @@ describe('transactional GPT integration module', () => { [{ status: 'failed', reason: 'slot_quarantined' }, 'slot_quarantined'], [{ status: 'failed', reason: 'gpt_request_timeout' }, 'gpt_request_timeout'], [{ status: 'failed', reason: 'gpt_completion_timeout' }, 'gpt_completion_timeout'], + [{ status: 'failed', reason: 'external_queue_full' }, 'external_queue_full'], + [{ status: 'failed', reason: 'external_ready_timeout' }, 'external_ready_timeout'], [{ status: 'cancelled', reason: 'navigation_disposed' }, 'navigation_disposed'], ] as const)( 'does not start fallback for non-empty terminal cycle outcome %s', @@ -569,7 +571,7 @@ describe('ordered GPT winner publication', () => { getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), observeTargeting: () => { order.push('observe'); - return vi.fn(); + return Object.assign(vi.fn(), { isCurrent: () => true }); }, refresh: vi.fn(), serviceState: () => diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts index 9de10d2f9..99b207791 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import type { - GoogletagAdapter, - GoogletagPublisherCallObserver, +import { + createBrowserGoogletagAdapter, + type GoogletagAdapter, + type GoogletagPublisherCallObserver, } from '../../../src/adapters/googletag'; import { createGptStartup } from '../../../src/integrations/gpt/startup'; -import type { SlotService } from '../../../src/services/slots'; +import { createSlotService, type SlotService } from '../../../src/services/slots'; describe('GPT startup bridge', () => { it('installs one reversible typed observer and delegates all handoff state to slots', () => { + const order: string[] = []; let observer: GoogletagPublisherCallObserver | undefined; const release = vi.fn(); const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { @@ -22,17 +24,27 @@ describe('GPT startup bridge', () => { preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'suppress' as const })), preparePublisherRefresh: vi.fn(() => Object.freeze({ action: 'suppress' as const })), recordPublisherDestruction: vi.fn(() => true), + start: vi.fn(() => { + order.push('slots:start'); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(), + dispose: vi.fn(), + }); + }), }) satisfies Pick< SlotService, | 'claimPublisherGptSlot' | 'preparePublisherDisplay' | 'preparePublisherRefresh' | 'recordPublisherDestruction' + | 'start' >; - const start = vi.fn(); + const start = vi.fn(() => order.push('external:start')); const startup = createGptStartup({ googletag: adapter, slots: () => slots, start }); expect(startup.activate()).toBe(release); + expect(slots.start).not.toHaveBeenCalled(); expect(observePublisherCalls).toHaveBeenCalledTimes(1); expect( observer?.defineSlot?.({ @@ -53,6 +65,28 @@ describe('GPT startup bridge', () => { const config = Object.freeze({ disableInitialLoad: true }); startup.start(config); + expect(slots.start).toHaveBeenCalledOnce(); expect(start).toHaveBeenCalledExactlyOnceWith(config); + expect(order).toEqual(['slots:start', 'external:start']); + }); + + it('keeps reversible activation timer-free and begins readiness only from start', () => { + vi.useFakeTimers(); + const adapter = createBrowserGoogletagAdapter({}); + const slots = createSlotService({ googletag: adapter }); + const startup = createGptStartup({ googletag: adapter, slots: () => slots }); + + const release = startup.activate(); + slots.activate(); + expect(vi.getTimerCount()).toBe(0); + + startup.start(Object.freeze({})); + expect(vi.getTimerCount()).toBe(1); + + release(); + slots.dispose(); + adapter.dispose(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 642907875..ce5e13ccd 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -68,7 +68,7 @@ function createGptHarness( clearTargeting: vi.fn(), display, getTargeting: vi.fn(() => []), - observeTargeting: () => vi.fn(), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), refresh: options.missingRefresh ? (undefined as unknown as GoogletagFacade['refresh']) : refresh, @@ -486,7 +486,13 @@ describe('slot registry', () => { it('hands an exact late publisher definition the TS slot and consumes only duplicate requests', async () => { const gpt = createGptHarness({ initialLoadDisabled: true }); - const service = createSlotService({ googletag: gpt.adapter }); + const warnPublisherHandoffMismatch = vi.fn(() => { + throw new Error('fictional local logger failure'); + }); + const service = createSlotService({ + googletag: gpt.adapter, + warnPublisherHandoffMismatch, + }); const { navigation, runtime } = createRuntimeWithNavigation(); const slot = bindTrustedSlot(service, navigation); @@ -498,6 +504,13 @@ describe('slot registry', () => { sizes: Object.freeze([[728, 90]]), }) ).toEqual({ action: 'handoff', slot }); + expect(warnPublisherHandoffMismatch).toHaveBeenCalledExactlyOnceWith( + 'GPT publisher handoff metadata mismatch', + Object.freeze({ formatsMismatch: true, pathMismatch: true }) + ); + expect(JSON.stringify(warnPublisherHandoffMismatch.mock.calls[0]).length).toBeLessThanOrEqual( + 128 + ); expect( service.preparePublisherDisplay({ initialLoadDisabled: true, target: 'slot-div' }) ).toEqual({ action: 'suppress' }); @@ -535,15 +548,37 @@ describe('slot registry', () => { expect(gpt.destroySlots).not.toHaveBeenCalled(); }); + it('does not warn when an exact publisher handoff matches path and formats', () => { + const warnPublisherHandoffMismatch = vi.fn(); + const service = createSlotService({ + googletag: createGptHarness().adapter, + warnPublisherHandoffMismatch, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: Object.freeze([[300, 250]]), + }) + ).toEqual({ action: 'handoff', slot }); + expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); + }); + it('hydrates only one disconnected TS fallback with the configured prefix, path, and sizes', () => { const dom = createReconciliationBoundary(); const firstElement = {}; const secondElement = {}; dom.put('slot-first', firstElement); dom.put('slot-second', secondElement); + const warnPublisherHandoffMismatch = vi.fn(); const service = createSlotService({ googletag: createGptHarness().adapter, reconciliation: dom.boundary, + warnPublisherHandoffMismatch, }); const navigation = createNavigation(); expect( @@ -585,6 +620,7 @@ describe('slot registry', () => { action: 'forward', }); expect(service.claimPublisherGptSlot(hydration)).toEqual({ action: 'handoff', slot: first }); + expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); }); it('suppresses the exact first explicit refresh after a disabled-load handoff', () => { @@ -879,6 +915,130 @@ describe('navigation-owned DOM reconciliation', () => { expect(gpt.defineSlot).not.toHaveBeenCalled(); }); + it.each([ + ['unresolved', 'destroy_false'], + ['unresolved', 'destroy_throw'], + ['ambiguous', 'destroy_false'], + ['ambiguous', 'destroy_throw'], + ] as const)('settles final %s cleanup %s as gpt_request_failed', async (resolution, failure) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + if (failure === 'destroy_false') gpt.destroySlots.mockReturnValue(false); + else { + gpt.destroySlots.mockImplementation(() => { + throw new Error('fictional destroy failure'); + }); + } + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + if (resolution === 'ambiguous') dom.replaceAmbiguously('slot-div', [{}, {}]); + else dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: `${resolution}-${failure}`, + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(1); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'gpt_request_failed', + }); + expect(gpt.destroySlots).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ id: 'slot' }), + ]); + }); + + it('keeps final cleanup pending and lets navigation cancellation beat its late result', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const { navigation, runtime } = createRuntimeWithNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: 'navigation-wins-late-cleanup', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + vi.advanceTimersByTime(1); + expect(request.status).toBe('active'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + expect(runtime.replaceNavigation().ok).toBe(true); + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'navigation_disposed', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + + it('lets request supersession win while final cleanup completes later', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const gpt = createGptHarness({ synchronousRun: false }); + const dom = createReconciliationBoundary(); + dom.put('slot-div', {}); + const service = createSlotService({ + googletag: gpt.adapter, + now: () => Date.now(), + reconciliation: dom.boundary, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + service.activate(); + dom.disconnect('slot-div'); + await vi.advanceTimersByTimeAsync(4_999); + const request = service.request({ + intentId: 'supersession-wins-late-cleanup', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + vi.advanceTimersByTime(1); + expect(request.status).toBe('active'); + request.dispose(); + await expect(request.result).resolves.toEqual({ + status: 'cancelled', + reason: 'superseded', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(request.status).toBe('terminal'); + expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + }); + it('releases the exact committed artifact before retiring a failed reconciliation', async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -1641,19 +1801,23 @@ function readyListenerBinding() { } describe('binding-aware GPT listener activation', () => { - it('retries after readiness timeout and never duplicates listeners on the recovered binding', async () => { + it('installs observation without timers and starts readiness only after commit', async () => { vi.useFakeTimers(); const target: { googletag?: unknown } = {}; const adapter = createBrowserGoogletagAdapter(target); const service = createSlotService({ googletag: adapter }); - const missing = service.activate(); + service.activate(); + expect(vi.getTimerCount()).toBe(0); + + const missing = service.start(); + expect(vi.getTimerCount()).toBe(1); await vi.advanceTimersByTimeAsync(10_000); await expect(missing.result).rejects.toMatchObject({ code: 'external_ready_timeout' }); const ready = readyListenerBinding(); target.googletag = ready.binding; - await expect(service.activate().result).resolves.toBeUndefined(); - await expect(service.activate().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); expect(ready.addEventListener.mock.calls.map(([type]) => type)).toEqual([ 'slotRequested', @@ -1667,10 +1831,11 @@ describe('binding-aware GPT listener activation', () => { const target: { googletag?: unknown } = { googletag: first.binding }; const adapter = createBrowserGoogletagAdapter(target); const service = createSlotService({ googletag: adapter }); - await expect(service.activate().result).resolves.toBeUndefined(); + service.activate(); + await expect(service.start().result).resolves.toBeUndefined(); target.googletag = second.binding; - await expect(service.activate().result).resolves.toBeUndefined(); - await expect(service.activate().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); + await expect(service.start().result).resolves.toBeUndefined(); expect(first.addEventListener).toHaveBeenCalledTimes(2); expect(second.addEventListener).toHaveBeenCalledTimes(2); @@ -1684,6 +1849,59 @@ describe('binding-aware GPT listener activation', () => { describe('physical GPT cycles', () => { afterEach(() => vi.useRealTimers()); + it('preserves external_queue_full when GPT readiness admission is saturated', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + for (let index = 0; index < 64; index += 1) { + const queued = adapter.run(() => undefined); + void queued.result.catch(() => undefined); + } + + const request = service.request({ + intentId: 'queue-capacity', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'external_queue_full', + }); + service.dispose(); + adapter.dispose(); + }); + + it('preserves external_ready_timeout when GPT never becomes ready', async () => { + vi.useFakeTimers(); + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + const service = createSlotService({ googletag: adapter }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const request = service.request({ + intentId: 'readiness-deadline', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + + await vi.advanceTimersByTimeAsync(10_000); + + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'external_ready_timeout', + }); + service.dispose(); + adapter.dispose(); + }); + it('records intent before a synchronous slotRequested event and supports SRA per slot', async () => { vi.useFakeTimers(); const harness = createGptHarness(); diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index cf472f9da..ffd7e9aca 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -205,6 +205,67 @@ describe('owner-aware targeting journal', () => { clearAll?.release(); expect(values.size).toBe(0); }); + + it.each(['same_set', 'different_set', 'per_key_clear', 'clear_all'] as const)( + 'invalidates after publisher wrapper replacement for %s without calling that replacement on release', + async (mutation) => { + const values = new Map([ + ['key', ['publisher']], + ['sibling', ['publisher-sibling']], + ]); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect( + service.observePublisherMutations(slot, adapter).result + ).resolves.toBeUndefined(); + const frame = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + + const publisherSet = vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const publisherClear = vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + if (mutation === 'same_set' || mutation === 'different_set') { + slot.setTargeting = publisherSet; + slot.setTargeting('key', mutation === 'same_set' ? 'trusted' : 'publisher-new'); + } else { + slot.clearTargeting = publisherClear; + slot.clearTargeting(mutation === 'per_key_clear' ? 'key' : undefined); + } + + frame?.release(); + + expect(publisherSet).toHaveBeenCalledTimes( + mutation === 'same_set' || mutation === 'different_set' ? 1 : 0 + ); + expect(publisherClear).toHaveBeenCalledTimes( + mutation === 'per_key_clear' || mutation === 'clear_all' ? 1 : 0 + ); + if (mutation === 'same_set') expect(values.get('key')).toEqual(['trusted']); + else if (mutation === 'different_set') expect(values.get('key')).toEqual(['publisher-new']); + else expect(values.get('key')).toBeUndefined(); + if (mutation === 'clear_all') expect(values.size).toBe(0); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + } + ); }); function adapterForTargetingSlot(slot: object) { @@ -291,6 +352,43 @@ describe('adapter-owned targeting interception', () => { expect(second).toHaveBeenCalledTimes(2); }); + it('reports wrapper replacement fail-closed and never overwrites a publisher replacement', async () => { + const originalSet = vi.fn(); + const originalClear = vi.fn(); + const replacementSet = vi.fn(); + const target = { + clearTargeting: originalClear, + getTargeting: () => [], + setTargeting: originalSet, + }; + let trapDescriptors = false; + const slot = new Proxy(target, { + getOwnPropertyDescriptor: (current, key) => { + if (trapDescriptors) throw new Error('publisher descriptor trap'); + return Reflect.getOwnPropertyDescriptor(current, key); + }, + }); + const adapter = adapterForTargetingSlot(slot); + const observation = await adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ).result; + + expect(observation.isCurrent()).toBe(true); + target.setTargeting = replacementSet; + expect(observation.isCurrent()).toBe(false); + observation(); + expect(target.setTargeting).toBe(replacementSet); + expect(target.clearTargeting).toBe(originalClear); + + const trapped = await adapter.run((gpt) => + gpt.observeTargeting(slot, { beforePublisherMutation: vi.fn() }) + ).result; + trapDescriptors = true; + expect(() => trapped.isCurrent()).not.toThrow(); + expect(trapped.isCurrent()).toBe(false); + expect(() => trapped()).not.toThrow(); + }); + it('rolls back the first method when transactional observer installation cannot wrap the second', async () => { const originalSet = vi.fn(); const originalClear = vi.fn(); From cc47b15286d4580636f7ffd76247448da15173e7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:15:25 -0700 Subject: [PATCH 356/494] Complete browser integration lifecycle wiring --- .../lib/src/composition/browser.ts | 30 +++- .../lib/test/composition/browser.test.ts | 141 +++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index de40a8133..9257e4c3f 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -20,7 +20,7 @@ import { } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; -import type { BrowserAuctionProjectionV1 } from '../core/types'; +import type { BrowserAuctionProjectionV1, CreativeBootV1 } from '../core/types'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -36,6 +36,10 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; +import { installClickGuard } from '../integrations/creative/click'; +import { installDynamicIframeProxy } from '../integrations/creative/iframe'; +import { installDynamicImageProxy } from '../integrations/creative/image'; +import { createCreativeStartup } from '../integrations/creative/startup'; import { publishGptWinner, startGptSlotOperation, @@ -169,6 +173,8 @@ export interface BrowserCoreActivations { export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { readonly auctionFetcherForTest?: AuctionBatchFetcher; readonly coreActivations: BrowserCoreActivations; + readonly creativeActivationForTest?: (config: Readonly) => () => void; + readonly creativeStartupForTest?: (config: Readonly) => void; readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; @@ -179,6 +185,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition interface AcceptedBrowserBoot { readonly auctionProjection: object; readonly cachePolicy?: unknown; + readonly creative: Readonly; readonly manifest: { readonly integrations: readonly { readonly id: string }[]; }; @@ -269,6 +276,23 @@ export function createTestBrowserRuntimeComposition( const composition = createBrowserComposition(compositionOptions); const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; + let creativeBoot: Readonly | undefined; + const defaultCreativeRuntime = + typeof document === 'undefined' + ? Object.freeze({ + activate: (_config: Readonly) => () => undefined, + start: (_config: Readonly) => undefined, + }) + : createCreativeStartup({ + document, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + }); + const creativeRuntime = Object.freeze({ + activate: compositionOptions.creativeActivationForTest ?? defaultCreativeRuntime.activate, + start: compositionOptions.creativeStartupForTest ?? defaultCreativeRuntime.start, + }); const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); const gptRuntime = createGptStartup({ googletag: composition.adapters.googletag, @@ -358,6 +382,7 @@ export function createTestBrowserRuntimeComposition( if (!descriptor || !('value' in descriptor)) return provided; config = descriptor.value; } + if (id === 'creative' && config === undefined) config = creativeBoot; const interfaces = runtimeSession?.interfaces; if (!interfaces) throw new Error(`Integration interfaces are unavailable for ${id}`); return Object.freeze({ @@ -555,6 +580,7 @@ export function createTestBrowserRuntimeComposition( }, prepareOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; + creativeBoot = boot.creative; const cachePolicy = boot.cachePolicy === undefined ? undefined : parseCacheFetchPolicyV1(boot.cachePolicy); const parseProjection = (candidate: unknown): object | undefined => @@ -758,6 +784,7 @@ export function createTestBrowserRuntimeComposition( compositionOptions.createIdentityIssuerForTest ?? createBrowserNavigationIdentityIssuer, interfaces: Object.freeze({ adapters: composition.adapters, + creative: creativeRuntime, gpt: gptRuntime, prebid: prebidRuntime, ...services, @@ -782,6 +809,7 @@ export function createTestBrowserRuntimeComposition( auctionBatchService = undefined; auctionContextRegistry = undefined; projectionParser = undefined; + creativeBoot = undefined; } }); const navigation = session.startInitialNavigation(initialProjection); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index ee57a3e28..16b530e4d 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -29,6 +29,7 @@ import { } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; +import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; @@ -73,7 +74,7 @@ function synchronousGptAdapter() { getTargeting: vi.fn((slot: object, key: string) => Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) ), - observeTargeting: () => vi.fn(), + observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), refresh, serviceState: () => Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), @@ -603,7 +604,8 @@ describe('browser composition', () => { expect(Object.isFrozen(composition.runtime)).toBe(true); }); - it('subscribes the injected slot service before correctness activation and disposes both listeners', async () => { + it('starts slot listeners before post-commit GPT startup and disposes both listeners', async () => { + const releaseId = 'a'.repeat(64); const subscriptions: string[] = []; const releases: string[] = []; const facade = { @@ -629,16 +631,20 @@ describe('browser composition', () => { _adapters: unknown, services: { readonly slots: { readonly snapshotForTest: () => { records: number } } } ) => { - expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); + expect(subscriptions).toEqual([]); expect(services.slots.snapshotForTest().records).toBe(0); } ); const composition = createTestBrowserRuntimeComposition( { target: {}, - releaseId: 'a'.repeat(64), - manifest: { version: 1, releaseId: 'a'.repeat(64), integrations: [] }, - knownIntegrationIds: Object.freeze([]), + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt']), boot: { auctionProjection: { version: 1, @@ -662,10 +668,16 @@ describe('browser composition', () => { coreActivations: { correctnessGptListeners: correctness, }, + gptStartupForTest: () => { + expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); + }, } ); expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); expect(correctness).toHaveBeenCalledOnce(); composition.runtime.dispose(); @@ -752,6 +764,123 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + it('injects the exact creative boot into reversible activation and post-commit startup', async () => { + const releaseId = 'a'.repeat(64); + const creative = Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: true, + renderGuard: false, + }); + const release = vi.fn(); + const activateCreative = vi.fn((received: unknown) => { + expect(received).toEqual(creative); + expect(Object.isFrozen(received)).toBe(true); + return release; + }); + const startCreative = vi.fn((received: unknown) => { + expect(received).toEqual(creative); + expect(Object.isFrozen(received)).toBe(true); + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'creative', required: true }], + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + creativeActivationForTest: activateCreative, + creativeStartupForTest: startCreative, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createCreativeIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(activateCreative).toHaveBeenCalledTimes(1); + expect(startCreative).toHaveBeenCalledTimes(1); + expect(activateCreative.mock.calls[0]?.[0]).toBe(startCreative.mock.calls[0]?.[0]); + + composition.runtime.dispose(); + composition.runtime.dispose(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('owns the real creative click guard through the composition lifecycle', async () => { + const releaseId = 'a'.repeat(64); + const addEventListener = vi.spyOn(document, 'addEventListener'); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'creative', required: true }], + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createCreativeIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(addEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + expect(addEventListener.mock.calls.filter(([type]) => type === 'auxclick')).toHaveLength(1); + } finally { + composition.runtime.dispose(); + addEventListener.mockRestore(); + } + expect(removeEventListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1); + expect(removeEventListener.mock.calls.filter(([type]) => type === 'auxclick')).toHaveLength(1); + removeEventListener.mockRestore(); + }); + it('publishes and promotes one exact Prebid winner through runtime-owned PUC state', async () => { const releaseId = 'a'.repeat(64); const prebid = synchronousPrebidAdapter(); From e82f3e798e7e19456d50fb802a7a7ab4cd0222e3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:20:27 -0700 Subject: [PATCH 357/494] Add bounded kernel diagnostics transport --- .../lib/src/kernel/diagnostics.ts | 222 ++++++++++++++++++ .../lib/test/kernel/diagnostics.test.ts | 154 ++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 crates/trusted-server-js/lib/src/kernel/diagnostics.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts diff --git a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts new file mode 100644 index 000000000..281e1f2c7 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts @@ -0,0 +1,222 @@ +import type { BootManifestV1 } from '../core/types'; + +const MAX_INTEGRATION_SUBSCRIPTIONS = 16; +const MAX_PENDING_OBSERVATIONS = 512; +const MAX_OBSERVATION_DEPTH = 16; +const MAX_OBSERVATION_NODES = 512; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export type DiagnosticsObservation = Readonly>; +export type DiagnosticsListener = (observation: DiagnosticsObservation) => void; + +export interface DiagnosticsScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + +export interface DiagnosticsBusOptions { + readonly manifest: Readonly; + readonly onOverflow?: (droppedObservations: number) => void; + readonly onSubscriberError?: (error: unknown) => void; + readonly pendingCapacity?: number; + readonly scheduler?: DiagnosticsScheduler; +} + +export interface DiagnosticsBus { + readonly publish: (observation: DiagnosticsObservation) => boolean; + readonly subscribe: (id: string, listener: DiagnosticsListener) => (() => void) | undefined; + readonly dispose: () => void; +} + +interface Subscription { + readonly id: string; + readonly listener: DiagnosticsListener; + active: boolean; +} + +interface PendingObservation { + readonly observation: DiagnosticsObservation; + readonly subscriptions: readonly Subscription[]; +} + +function defaultScheduler(): DiagnosticsScheduler { + return Object.freeze({ + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function recursivelyFrozenRecord(candidate: unknown): candidate is DiagnosticsObservation { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return false; + const visited = new Set(); + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return true; + if (visited.has(value)) return true; + if (depth > MAX_OBSERVATION_DEPTH || nodes >= MAX_OBSERVATION_NODES) return false; + visited.add(value); + nodes += 1; + try { + if (typeof value === 'function') return true; + const prototype = Object.getPrototypeOf(value) as unknown; + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + // GPT physical-slot objects are opaque identities, not diagnostic data. + return true; + } + if (!Object.isFrozen(value)) return false; + const keys = Reflect.ownKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (key === undefined) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor) || !visit(descriptor.value, depth + 1)) { + return false; + } + } + return true; + } catch { + return false; + } + }; + return visit(candidate, 0); +} + +/** Create the closure-private, failure-isolated diagnostics transport for one runtime. */ +export function createDiagnosticsBus(options: DiagnosticsBusOptions): DiagnosticsBus { + const allowedIds = new Set(); + try { + const integrationsDescriptor = Object.getOwnPropertyDescriptor( + options.manifest, + 'integrations' + ); + const integrations = + integrationsDescriptor && 'value' in integrationsDescriptor + ? (integrationsDescriptor.value as readonly unknown[]) + : []; + for (let index = 0; index < integrations.length; index += 1) { + const entry = integrations[index]; + if (typeof entry !== 'object' || entry === null) continue; + const idDescriptor = Object.getOwnPropertyDescriptor(entry, 'id'); + const id = idDescriptor && 'value' in idDescriptor ? idDescriptor.value : undefined; + if (typeof id === 'string' && INTEGRATION_ID.test(id)) allowedIds.add(id); + } + } catch { + // Invalid manifest identities admit no diagnostic consumers. + } + const pendingCapacity = + Number.isSafeInteger(options.pendingCapacity) && + (options.pendingCapacity ?? 0) > 0 && + (options.pendingCapacity ?? 0) <= MAX_PENDING_OBSERVATIONS + ? options.pendingCapacity! + : MAX_PENDING_OBSERVATIONS; + const scheduler = options.scheduler ?? defaultScheduler(); + const subscriptions = new Map(); + const pending: PendingObservation[] = []; + let disposed = false; + let droppedObservations = 0; + let scheduled = false; + let scheduledHandle: unknown; + + const reportSubscriberError = (error: unknown): void => { + try { + options.onSubscriberError?.(error); + } catch { + // Diagnostics error reporting is observation only. + } + }; + + const drain = (): void => { + scheduled = false; + scheduledHandle = undefined; + if (disposed) { + pending.length = 0; + return; + } + while (pending.length > 0 && !disposed) { + const item = pending.shift(); + if (!item) continue; + for (let index = 0; index < item.subscriptions.length; index += 1) { + const subscription = item.subscriptions[index]; + if (!subscription?.active || subscriptions.get(subscription.id) !== subscription) { + continue; + } + try { + subscription.listener(item.observation); + } catch (error) { + reportSubscriberError(error); + } + } + } + }; + + const scheduleDrain = (): boolean => { + if (scheduled) return true; + scheduled = true; + try { + const handle = scheduler.set(drain, 0); + if (scheduled) scheduledHandle = handle; + return true; + } catch { + scheduled = false; + scheduledHandle = undefined; + pending.length = 0; + return false; + } + }; + + return Object.freeze({ + publish: (observation: DiagnosticsObservation): boolean => { + if (disposed || !recursivelyFrozenRecord(observation)) return false; + const captured = Object.freeze([...subscriptions.values()]); + if (captured.length === 0) return true; + if (pending.length >= pendingCapacity) { + pending.shift(); + droppedObservations += 1; + try { + options.onOverflow?.(droppedObservations); + } catch { + // Diagnostics overflow accounting cannot affect correctness work. + } + } + pending.push(Object.freeze({ observation, subscriptions: captured })); + return scheduleDrain(); + }, + subscribe: (id: string, listener: DiagnosticsListener): (() => void) | undefined => { + if ( + disposed || + typeof listener !== 'function' || + !allowedIds.has(id) || + subscriptions.has(id) || + subscriptions.size >= MAX_INTEGRATION_SUBSCRIPTIONS + ) { + return undefined; + } + const subscription: Subscription = { id, listener, active: true }; + subscriptions.set(id, subscription); + return (): void => { + if (!subscription.active) return; + subscription.active = false; + if (subscriptions.get(id) === subscription) subscriptions.delete(id); + }; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + for (const subscription of subscriptions.values()) subscription.active = false; + subscriptions.clear(); + pending.length = 0; + if (scheduled) { + scheduled = false; + try { + scheduler.clear(scheduledHandle); + } catch { + // The disposed flag suppresses a hostile late scheduler callback. + } + } + scheduledHandle = undefined; + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts new file mode 100644 index 000000000..0b02a85f8 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BootManifestV1 } from '../../src/core/types'; +import { createDiagnosticsBus, type DiagnosticsObservation } from '../../src/kernel/diagnostics'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]): BootManifestV1 { + return Object.freeze({ + version: 1, + releaseId: RELEASE_ID, + integrations: Object.freeze(ids.map((id) => Object.freeze({ id, required: true as const }))), + }); +} + +function observation(sequence: number): DiagnosticsObservation { + return Object.freeze({ + kind: 'render', + sequence, + value: Object.freeze({ slotId: `slot-${sequence}` }), + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('kernel diagnostics bus', () => { + it('exposes only a frozen private-owner facade', () => { + const bus = createDiagnosticsBus({ manifest: manifest([]) }); + + expect(Object.isFrozen(bus)).toBe(true); + expect(Reflect.ownKeys(bus).sort()).toEqual(['dispose', 'publish', 'subscribe']); + expect('listeners' in bus).toBe(false); + expect('pending' in bus).toBe(false); + + bus.dispose(); + }); + + it('admits only one live subscription for an exact manifest member', () => { + const bus = createDiagnosticsBus({ manifest: manifest(['gpt_diagnostics']) }); + const first = bus.subscribe('gpt_diagnostics', vi.fn()); + + expect(first).toEqual(expect.any(Function)); + expect(bus.subscribe('gpt_diagnostics', vi.fn())).toBeUndefined(); + expect(bus.subscribe('not_in_manifest', vi.fn())).toBeUndefined(); + + first?.(); + expect(bus.subscribe('gpt_diagnostics', vi.fn())).toEqual(expect.any(Function)); + bus.dispose(); + }); + + it('admits sixteen live module identities and rejects a seventeenth without disturbance', () => { + vi.useFakeTimers(); + const ids = Array.from({ length: 17 }, (_, index) => `module_${index}`); + const bus = createDiagnosticsBus({ manifest: manifest(ids) }); + const listeners = ids.map(() => vi.fn()); + + for (let index = 0; index < 16; index += 1) { + expect(bus.subscribe(ids[index]!, listeners[index]!)).toEqual(expect.any(Function)); + } + expect(bus.subscribe(ids[16]!, listeners[16]!)).toBeUndefined(); + + expect(bus.publish(observation(1))).toBe(true); + expect(listeners.every((listener) => listener.mock.calls.length === 0)).toBe(true); + vi.runOnlyPendingTimers(); + expect(listeners.slice(0, 16).every((listener) => listener.mock.calls.length === 1)).toBe(true); + expect(listeners[16]).not.toHaveBeenCalled(); + bus.dispose(); + }); + + it('delivers frozen observations asynchronously in order and isolates subscriber throws', () => { + vi.useFakeTimers(); + const errors: unknown[] = []; + const bus = createDiagnosticsBus({ + manifest: manifest(['thrower', 'observer']), + onSubscriberError: (error) => errors.push(error), + }); + const received: number[] = []; + bus.subscribe('thrower', () => { + throw new Error('fictional diagnostics failure'); + }); + bus.subscribe('observer', (event) => { + expect(Object.isFrozen(event)).toBe(true); + if (typeof event.sequence === 'number') received.push(event.sequence); + }); + + expect(bus.publish(observation(1))).toBe(true); + expect(bus.publish(observation(2))).toBe(true); + expect(received).toEqual([]); + + vi.runOnlyPendingTimers(); + expect(received).toEqual([1, 2]); + expect(errors).toHaveLength(2); + bus.dispose(); + }); + + it('uses publish-time membership while honoring unsubscribe before delivery', () => { + vi.useFakeTimers(); + const bus = createDiagnosticsBus({ manifest: manifest(['first', 'second']) }); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = bus.subscribe('first', first); + + bus.publish(observation(1)); + const releaseSecond = bus.subscribe('second', second); + releaseFirst?.(); + vi.runOnlyPendingTimers(); + + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + bus.publish(observation(2)); + vi.runOnlyPendingTimers(); + expect(second).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledWith(observation(2)); + releaseSecond?.(); + bus.dispose(); + }); + + it('bounds pending delivery and cancels all work on disposal', () => { + vi.useFakeTimers(); + const bus = createDiagnosticsBus({ + manifest: manifest(['observer']), + pendingCapacity: 2, + }); + const listener = vi.fn(); + bus.subscribe('observer', listener); + + bus.publish(observation(1)); + bus.publish(observation(2)); + bus.publish(observation(3)); + vi.runOnlyPendingTimers(); + + expect(listener.mock.calls.map(([event]) => event.sequence)).toEqual([2, 3]); + + bus.publish(observation(4)); + bus.dispose(); + vi.runOnlyPendingTimers(); + expect(listener).toHaveBeenCalledTimes(2); + expect(bus.publish(observation(5))).toBe(false); + expect(bus.subscribe('observer', vi.fn())).toBeUndefined(); + }); + + it('rejects mutable observations without reading them', () => { + const bus = createDiagnosticsBus({ manifest: manifest([]) }); + const read = vi.fn(); + const mutable = Object.defineProperty({}, 'kind', { enumerable: true, get: read }); + + expect(bus.publish(mutable as DiagnosticsObservation)).toBe(false); + expect(read).not.toHaveBeenCalled(); + bus.dispose(); + }); +}); From e49b0617f028434fec02c572b08bb7a7d78f3e11 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:24:05 -0700 Subject: [PATCH 358/494] Close GPT cleanup race windows --- .../lib/src/services/slots.ts | 8 +++ .../lib/src/services/targeting.ts | 8 +++ .../lib/test/services/slots.test.ts | 55 +++++++++++++++++-- .../lib/test/services/targeting.test.ts | 45 +++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index e9a348eff..1521f50d6 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -1628,6 +1628,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { void destroyOperation.result.then( (result) => { if (result.status === 'destroyed') { + if (window.terminal || record.reconciliation !== window) { + detachDestroyedReconciliationPhysical(physical); + return; + } finishFailedReconciliation(record, window, reason, true); return; } @@ -1640,6 +1644,10 @@ export function createSlotService(options: SlotServiceOptions): SlotService { replacementError?.oldSlotDestroyed === true && replacementError.preserveOldQuarantine !== true && !reusedOldIdentity; + if (destroyed && (window.terminal || record.reconciliation !== window)) { + detachDestroyedReconciliationPhysical(physical); + return; + } finishFailedReconciliation(record, window, 'gpt_request_failed', destroyed); } ); diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index 74c795529..a00daa8f6 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -284,6 +284,10 @@ export function createTargetingService(): TargetingService { } catch { return false; } + if (frame.observation && !observationIsCurrent(frame.observation)) { + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; + } if (!exactInstalledValue(actual, frame.installed)) { invalidateChain(frame.slot, slotChains, frame.key, chain); return true; @@ -291,6 +295,10 @@ export function createTargetingService(): TargetingService { const expected = expectedPredecessor(frame); if (!expected) return false; + if (frame.observation && !observationIsCurrent(frame.observation)) { + invalidateChain(frame.slot, slotChains, frame.key, chain); + return true; + } try { restorePredecessor(frame); } catch { diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index ce5e13ccd..b903026fd 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -42,6 +42,7 @@ function createRuntimeWithNavigation() { function createGptHarness( options: { initialLoadDisabled?: boolean; + deferDestroyedResult?: boolean; missingRefresh?: boolean; orphanOnReplace?: object; returnOldOnReplace?: boolean; @@ -63,6 +64,12 @@ function createGptHarness( const addService = vi.fn(); const operationDisposals: Array> = []; const bindingToken = Object.freeze({}); + let deferredDestroyedResolved = false; + let resolveDeferredDestroyedPromise!: () => void; + const deferredDestroyedPromise = new Promise((resolve) => { + resolveDeferredDestroyedPromise = resolve; + }); + let deferredDestroyedUsed = false; const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, clearTargeting: vi.fn(), @@ -144,7 +151,20 @@ function createGptHarness( let result: Promise; if (options.synchronousRun !== false) { try { - result = Promise.resolve(command(facade)); + const value = command(facade); + const deferResult = + options.deferDestroyedResult === true && + !deferredDestroyedUsed && + typeof value === 'object' && + value !== null && + 'status' in value && + value.status === 'destroyed'; + if (deferResult) { + deferredDestroyedUsed = true; + result = deferredDestroyedPromise.then(() => value); + } else { + result = Promise.resolve(value); + } } catch (error) { result = Promise.reject(error); } @@ -173,6 +193,11 @@ function createGptHarness( facade, operationDisposals, refresh, + resolveDeferredDestroyed: () => { + if (deferredDestroyedResolved) return; + deferredDestroyedResolved = true; + resolveDeferredDestroyedPromise(); + }, }; } @@ -965,7 +990,7 @@ describe('navigation-owned DOM reconciliation', () => { it('keeps final cleanup pending and lets navigation cancellation beat its late result', async () => { vi.useFakeTimers(); vi.setSystemTime(0); - const gpt = createGptHarness(); + const gpt = createGptHarness({ deferDestroyedResult: true }); const dom = createReconciliationBoundary(); dom.put('slot-div', {}); const service = createSlotService({ @@ -974,7 +999,7 @@ describe('navigation-owned DOM reconciliation', () => { reconciliation: dom.boundary, }); const { navigation, runtime } = createRuntimeWithNavigation(); - bindTrustedSlot(service, navigation); + const oldSlot = bindTrustedSlot(service, navigation); service.activate(); dom.disconnect('slot-div'); await vi.advanceTimersByTimeAsync(4_999); @@ -989,16 +1014,38 @@ describe('navigation-owned DOM reconciliation', () => { vi.advanceTimersByTime(1); expect(request.status).toBe('active'); expect(gpt.destroySlots).toHaveBeenCalledTimes(1); - expect(runtime.replaceNavigation().ok).toBe(true); + const nextResult = runtime.replaceNavigation(); + expect(nextResult.ok).toBe(true); + if (!nextResult.ok) throw new Error('Expected replacement navigation'); + const next = nextResult.value; await expect(request.result).resolves.toEqual({ status: 'cancelled', reason: 'navigation_disposed', }); + expect( + service.register(next, [ + serverRegistration('slot', { + adUnitCode: '/network/slot', + domAliases: ['slot-div'], + }), + ]) + ).toEqual({ ok: false, reason: 'slot_quarantined' }); + + gpt.resolveDeferredDestroyed(); await Promise.resolve(); await Promise.resolve(); expect(request.status).toBe('terminal'); expect(gpt.destroySlots).toHaveBeenCalledTimes(1); + const replacement = bindTrustedSlot(service, next); + gpt.resolveDeferredDestroyed(); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'late-old-slot', + slot: oldSlot, + }); + expect(service.recordPublisherDestruction(oldSlot)).toBe(false); + expect(service.isBoundGptSlot(next.generation, 'slot', replacement)).toBe(true); }); it('lets request supersession win while final cleanup completes later', async () => { diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index ffd7e9aca..c637d41bc 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -266,6 +266,51 @@ describe('owner-aware targeting journal', () => { expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); } ); + + it('invalidates when a targeting read replaces an observed wrapper during release', async () => { + const values = new Map([['key', ['publisher']]]); + const publisherReplacement = vi.fn((key: string, value: string | readonly string[]): void => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + const frame = service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => { + adapter.run((gpt) => gpt.clearTargeting(slot, key)); + }, + getTargeting: (key) => { + let current: readonly string[] = Object.freeze([]); + adapter.run((gpt) => { + current = gpt.getTargeting(slot, key); + }); + return current; + }, + setTargeting: (key, value) => { + adapter.run((gpt) => gpt.setTargeting(slot, key, value)); + }, + }); + slot.getTargeting.mockImplementationOnce((key: string) => { + slot.setTargeting = publisherReplacement; + return Object.freeze([...(values.get(key) ?? [])]); + }); + + frame?.release(); + + expect(publisherReplacement).not.toHaveBeenCalled(); + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); }); function adapterForTargetingSlot(slot: object) { From 226a08fe90cf63baf3ed7015d2cc0b3e3c6a5da6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:31:33 -0700 Subject: [PATCH 359/494] Add bounded render trace diagnostics --- .../trusted-server-js/lib/src/core/trace.ts | 365 ++++++++++++++++-- .../lib/test/core/trace_runtime.test.ts | 201 ++++++++++ 2 files changed, 534 insertions(+), 32 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/core/trace_runtime.test.ts diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 67c9d07d6..456902f0e 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -1,23 +1,16 @@ // Render-trace registry, DOM markers, and a floating debug panel: joins a // creative rendered on the page back to the winning server-side auction bid. -// Every render writes a RenderRecord to window.tsjs.renders (keyed by slot ID), -// stamps the slot element with data-ts-* attributes carrying the same trace -// tuple, and fires a 'tsjs:adRendered' CustomEvent. When the ts-trace cookie is -// armed (via GET /_ts/trace), a Google-Publisher-Console-style overlay panel -// summarises every traced slot so an operator can confirm on the page itself -// that creatives came through Trusted Server — on both the SSAT/GAM and -// /auction render paths. import { log } from './log'; -import type { LegacyTsjsApi, RenderRecord } from './types'; +import type { + LegacyTsjsApi, + RenderRecord, + RenderTraceDiagnostics, + RenderTraceRecord, +} from './types'; /** CustomEvent fired on window after each render-trace record is written. */ export const RENDER_EVENT_NAME = 'tsjs:adRendered'; -/** - * Cookie armed by `GET /_ts/trace` (server-side, `ts-trace=1`). While present, - * the floating trace panel is shown so an operator can see on the page itself - * that creatives were delivered by Trusted Server. - */ const TRACE_COOKIE_NAME = 'ts-trace'; /** DOM id of the floating trace panel (body-level overlay). */ @@ -29,23 +22,8 @@ export const TRACE_PANEL_ID = 'ts-render-trace-panel'; * history is trimmed from the front rather than growing without limit. */ const MAX_RENDER_LOG_ENTRIES = 200; - -/** - * Fallback for [`nextRenderSeq`] when `window.tsjs` is unreachable (no DOM, or - * a throwing property access). Never the primary counter — see below. - */ let fallbackRenderSeq = 0; -/** - * Allocate the next value for [`RenderRecord.seq`]. - * - * The counter lives on the shared `window.tsjs` object, not in module scope: - * `build-all.mjs` emits core, GPT and every integration as separate - * self-contained IIFEs, each with its own inlined copy of this module. A - * module-scoped counter would therefore restart at 1 in each bundle and hand - * two different renders the same number — duplicate `#1` panel rows and - * badges across the SSAT and `/auction` paths. - */ function nextRenderSeq(): number { try { const ts = (window.tsjs ??= {} as LegacyTsjsApi); @@ -450,8 +428,6 @@ export function recordRender(record: Omit) const prev = renders[record.slotId]; if (prev) full.count = prev.count + 1; renders[record.slotId] = full; - - // Keep each render as its own history entry, trimmed from the front. const history = (ts.renderLog ??= []); history.push(full); if (history.length > MAX_RENDER_LOG_ENTRIES) { @@ -463,7 +439,6 @@ export function recordRender(record: Omit) try { window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: full })); } catch (err) { - // CustomEvent unavailable — registry entry above is still written. log.debug('trace: failed to dispatch render event', { slotId: record.slotId, err }); } renderTracePanel(); @@ -518,7 +493,6 @@ export function updateRender(record: RenderRecord, patch: RenderUpdate): RenderR try { window.dispatchEvent(new CustomEvent(RENDER_EVENT_NAME, { detail: record })); } catch (err) { - // CustomEvent unavailable — the mutated record above still stands. log.debug('trace: failed to dispatch render update event', { slotId: record.slotId, err }); } renderTracePanel(); @@ -580,3 +554,330 @@ export function stampCreativeTrace(el: Element, record: RenderRecord): void { log.warn('trace: failed to stamp element', { slotId: record.slotId, err }); } } + +const MAX_RENDER_TRACE_SLOTS = 256; +const MAX_RENDER_TRACE_SUBSCRIBERS = 32; +const MAX_RENDER_TRACE_NOTIFICATIONS = 200; + +type RenderTraceInputV1 = Omit; +type RenderTraceUpdateV1 = Partial>; + +export interface RenderTraceRuntimeScheduler { + readonly set: (callback: () => void, milliseconds: number) => unknown; + readonly clear: (handle: unknown) => void; +} + +export interface RenderTraceRuntimeOptions { + readonly now?: () => number; + readonly onOverflow?: (droppedNotifications: number) => void; + readonly onSubscriberError?: (error: unknown) => void; + readonly schedule?: (callback: () => void) => () => void; + readonly scheduler?: RenderTraceRuntimeScheduler; +} + +export interface RenderTraceRuntimeOwner { + readonly api: RenderTraceDiagnostics; + readonly diagnostics: RenderTraceDiagnostics; + readonly record: (input: RenderTraceInputV1) => Readonly; + readonly enrich: ( + recordOrSequence: Readonly | number, + patch: RenderTraceUpdateV1 + ) => Readonly | undefined; + readonly prune: (slotId: string, sequence?: number) => boolean; + readonly dispose: () => void; +} + +export class DiagnosticsSubscriberLimitError extends Error { + public readonly code = 'subscriber_capacity' as const; + public readonly surface: 'renderTrace' | 'gpt'; + + public constructor(surface: 'renderTrace' | 'gpt') { + super('subscriber_capacity'); + this.name = 'DiagnosticsSubscriberLimitError'; + this.surface = surface; + } +} + +interface RenderTraceSubscription { + readonly id: number; + readonly listener: (record: Readonly) => void; +} + +interface PendingRenderTraceNotification { + readonly record: Readonly; + readonly subscriberIds: readonly number[]; +} + +function copyRenderTraceRecord(record: Readonly): Readonly { + const copy: Record = { + slotId: record.slotId, + path: record.path, + rendered: record.rendered, + }; + const optional = [ + 'elementId', + 'auctionId', + 'bidder', + 'adId', + 'bidId', + 'creativeId', + 'admHash', + 'servedFrom', + 'gamEmpty', + 'injected', + 'visible', + ] as const; + for (const key of optional) { + const value = record[key]; + if (value !== undefined) copy[key] = value; + } + copy.count = record.count; + copy.seq = record.seq; + copy.at = record.at; + return Object.freeze(copy) as unknown as Readonly; +} + +function scheduleRenderTraceTask(callback: () => void): () => void { + const handle = globalThis.setTimeout(callback, 0); + return (): void => globalThis.clearTimeout(handle); +} + +/** Create one document-runtime render trace without exposing its mutation authority. */ +export function createRenderTraceDiagnostics( + options: RenderTraceRuntimeOptions = {} +): RenderTraceRuntimeOwner { + const current = new Map>(); + const history: Array> = []; + const recordsBySequence = new Map>(); + const subscribers = new Map(); + const pendingOrder: number[] = []; + const pendingBySequence = new Map(); + let sequence = 0; + let subscriberSequence = 0; + let droppedNotifications = 0; + let reportedDroppedNotifications = 0; + let cancelScheduled: (() => void) | undefined; + let disposed = false; + + const schedule = (callback: () => void): (() => void) => { + if (options.schedule) return options.schedule(callback); + if (options.scheduler) { + const handle = options.scheduler.set(callback, 0); + return (): void => options.scheduler?.clear(handle); + } + return scheduleRenderTraceTask(callback); + }; + + const reportSubscriberError = (error: unknown): void => { + try { + options.onSubscriberError?.(error); + } catch { + // Diagnostics error reporting cannot affect correctness work. + } + }; + + const drain = (): void => { + cancelScheduled = undefined; + if (droppedNotifications !== reportedDroppedNotifications) { + reportedDroppedNotifications = droppedNotifications; + try { + options.onOverflow?.(droppedNotifications); + } catch { + // Diagnostics-only overflow reporting stays inside the diagnostics task. + } + } + while (!disposed && pendingOrder.length > 0) { + const next = pendingOrder.shift(); + if (next === undefined) continue; + const pending = pendingBySequence.get(next); + pendingBySequence.delete(next); + if (!pending) continue; + for (const id of pending.subscriberIds) { + const subscription = subscribers.get(id); + if (!subscription) continue; + try { + subscription.listener(pending.record); + } catch (error) { + reportSubscriberError(error); + } + } + } + }; + + const ensureDrain = (): boolean => { + if (cancelScheduled) return true; + try { + const cancel = schedule(drain); + if (typeof cancel !== 'function') throw new TypeError('invalid diagnostics scheduler'); + if (!disposed && pendingOrder.length > 0) cancelScheduled = cancel; + return true; + } catch { + pendingOrder.length = 0; + pendingBySequence.clear(); + cancelScheduled = undefined; + return false; + } + }; + + const enqueue = (record: Readonly): void => { + if (disposed || subscribers.size === 0) return; + const pending = Object.freeze({ + record: copyRenderTraceRecord(record), + subscriberIds: Object.freeze([...subscribers.keys()]), + }); + if (pendingBySequence.has(record.seq)) { + pendingBySequence.set(record.seq, pending); + return; + } + if (pendingOrder.length >= MAX_RENDER_TRACE_NOTIFICATIONS) { + const dropped = pendingOrder.shift(); + if (dropped !== undefined) pendingBySequence.delete(dropped); + droppedNotifications += 1; + } + pendingOrder.push(record.seq); + pendingBySequence.set(record.seq, pending); + ensureDrain(); + }; + + const retained = (record: Readonly): boolean => + current.get(record.slotId)?.seq === record.seq || + history.some((candidate) => candidate.seq === record.seq); + + const record = (input: RenderTraceInputV1): Readonly => { + const previous = current.get(input.slotId); + let at: number; + try { + at = (options.now ?? Date.now)(); + } catch { + at = Date.now(); + } + const committed = copyRenderTraceRecord({ + ...input, + count: (previous?.count ?? 0) + 1, + seq: (sequence += 1), + at, + }); + if (disposed) return committed; + if (!previous && current.size >= MAX_RENDER_TRACE_SLOTS) { + const oldestSlot = current.keys().next().value as string | undefined; + if (oldestSlot !== undefined) current.delete(oldestSlot); + } + current.set(committed.slotId, committed); + recordsBySequence.set(committed.seq, committed); + history.push(committed); + if (history.length > MAX_RENDER_LOG_ENTRIES) { + const evicted = history.shift(); + if (evicted && !retained(evicted)) recordsBySequence.delete(evicted.seq); + } + if (previous && !retained(previous)) recordsBySequence.delete(previous.seq); + enqueue(committed); + return committed; + }; + + const enrich = ( + recordOrSequence: Readonly | number, + patch: RenderTraceUpdateV1 + ): Readonly | undefined => { + if (disposed) return undefined; + const targetSequence = + typeof recordOrSequence === 'number' ? recordOrSequence : recordOrSequence?.seq; + if (!Number.isSafeInteger(targetSequence) || targetSequence <= 0) return undefined; + const existing = recordsBySequence.get(targetSequence); + if (!existing) return undefined; + const injected = + existing.injected === true || patch.injected === true + ? { injected: true as const } + : existing.injected === false || patch.injected === false + ? { injected: false as const } + : {}; + const merged = { + ...existing, + ...patch, + rendered: + existing.rendered === true && patch.rendered === false + ? true + : (patch.rendered ?? existing.rendered), + ...injected, + slotId: existing.slotId, + count: existing.count, + seq: existing.seq, + at: existing.at, + } as RenderTraceRecord; + const committed = copyRenderTraceRecord(merged); + recordsBySequence.set(targetSequence, committed); + if (current.get(existing.slotId)?.seq === targetSequence) { + current.set(existing.slotId, committed); + } + const historyIndex = history.findIndex(({ seq }) => seq === targetSequence); + if (historyIndex >= 0) history[historyIndex] = committed; + enqueue(committed); + return committed; + }; + + const prune = (slotId: string, expectedSequence?: number): boolean => { + if (disposed || typeof slotId !== 'string') return false; + const existing = current.get(slotId); + if (!existing || (expectedSequence !== undefined && existing.seq !== expectedSequence)) { + return false; + } + current.delete(slotId); + if (!retained(existing)) recordsBySequence.delete(existing.seq); + return true; + }; + + const api: RenderTraceDiagnostics = Object.freeze({ + current: (): Readonly>> => { + const snapshot = Object.create(null) as Record>; + for (const [slotId, traceRecord] of current) { + Object.defineProperty(snapshot, slotId, { + configurable: false, + enumerable: true, + value: copyRenderTraceRecord(traceRecord), + writable: false, + }); + } + return Object.freeze(snapshot); + }, + history: (): readonly Readonly[] => + Object.freeze(history.map((traceRecord) => copyRenderTraceRecord(traceRecord))), + subscribe: (listener: (record: Readonly) => void): (() => void) => { + if (typeof listener !== 'function') + throw new TypeError('diagnostics listener must be callable'); + if (disposed) return () => undefined; + if (subscribers.size >= MAX_RENDER_TRACE_SUBSCRIBERS) { + throw new DiagnosticsSubscriberLimitError('renderTrace'); + } + const id = (subscriberSequence += 1); + const subscription = Object.freeze({ id, listener }); + subscribers.set(id, subscription); + let active = true; + return (): void => { + if (!active) return; + active = false; + if (subscribers.get(id) === subscription) subscribers.delete(id); + }; + }, + }); + + const dispose = (): void => { + if (disposed) return; + disposed = true; + try { + cancelScheduled?.(); + } catch { + // The disposed latch suppresses a hostile late callback. + } + cancelScheduled = undefined; + subscribers.clear(); + pendingOrder.length = 0; + pendingBySequence.clear(); + current.clear(); + history.length = 0; + recordsBySequence.clear(); + }; + + return Object.freeze({ api, diagnostics: api, record, enrich, prune, dispose }); +} + +/** Short name used by the browser composition owner. */ +export const createRenderTrace = createRenderTraceDiagnostics; diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts new file mode 100644 index 000000000..a5d7671ed --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createRenderTrace, DiagnosticsSubscriberLimitError } from '../../src/core/trace'; + +function harness() { + const tasks: Array<() => void> = []; + const owner = createRenderTrace({ + scheduler: { + set: (callback) => { + tasks.push(callback); + return callback; + }, + clear: (handle) => { + const index = tasks.indexOf(handle as () => void); + if (index >= 0) tasks.splice(index, 1); + }, + }, + }); + return { + owner, + tasks, + drain: (): void => { + while (tasks.length > 0) tasks.shift()?.(); + }, + }; +} + +describe('render trace diagnostics runtime', () => { + it('exposes one exact frozen read-only public surface with copied snapshots', () => { + const { owner } = harness(); + const target = window as typeof window & { tsjs?: Record }; + const existingApi = (target.tsjs = {}); + const event = vi.fn(); + window.addEventListener('tsjs:adRendered', event); + const record = owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + + expect(Reflect.ownKeys(owner.diagnostics).sort()).toEqual(['current', 'history', 'subscribe']); + expect(Object.isFrozen(owner.diagnostics)).toBe(true); + const current = owner.diagnostics.current(); + const history = owner.diagnostics.history(); + expect(Object.isFrozen(current)).toBe(true); + expect(Object.isFrozen(history)).toBe(true); + expect(Object.isFrozen(current['slot-a'])).toBe(true); + expect(current['slot-a']).toEqual(record); + expect(current['slot-a']).not.toBe(record); + expect(history[0]).toEqual(record); + expect(history[0]).not.toBe(record); + expect(target.tsjs).toBe(existingApi); + expect(target.tsjs).toEqual({}); + expect(event).not.toHaveBeenCalled(); + window.removeEventListener('tsjs:adRendered', event); + delete target.tsjs; + }); + + it('commits before one asynchronous frozen public delivery', () => { + const { owner, tasks, drain } = harness(); + const listener = vi.fn(); + owner.diagnostics.subscribe(listener); + + const record = owner.record({ slotId: 'slot-a', path: 'ssat', rendered: true }); + + expect(owner.diagnostics.current()['slot-a']).toEqual(record); + expect(listener).not.toHaveBeenCalled(); + expect(tasks).toHaveLength(1); + drain(); + expect(listener).toHaveBeenCalledTimes(1); + const delivered = listener.mock.calls[0]?.[0]; + expect(delivered).toEqual(record); + expect(delivered).not.toBe(record); + expect(Object.isFrozen(delivered)).toBe(true); + }); + + it('enforces the 32-subscriber cap after callable validation and reuses capacity', () => { + const { owner } = harness(); + const releases = Array.from({ length: 32 }, () => owner.diagnostics.subscribe(() => undefined)); + + expect(() => owner.diagnostics.subscribe(null as never)).toThrow(TypeError); + expect(() => owner.diagnostics.subscribe(() => undefined)).toThrowError( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'renderTrace' }) + ); + expect(() => owner.diagnostics.subscribe(() => undefined)).toThrow( + DiagnosticsSubscriberLimitError + ); + releases[0]?.(); + releases[0]?.(); + expect(owner.diagnostics.subscribe(() => undefined)).toBeTypeOf('function'); + }); + + it('captures membership per commit and suppresses unsubscribe before delivery', () => { + const { owner, drain } = harness(); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = owner.diagnostics.subscribe(first); + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + releaseFirst(); + owner.diagnostics.subscribe(second); + drain(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + owner.record({ slotId: 'slot-b', path: 'auction', rendered: true }); + drain(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('coalesces pending same-impression enrichment without changing FIFO order', () => { + const { owner, drain, tasks } = harness(); + const received: Array<{ seq: number; injected?: boolean }> = []; + owner.diagnostics.subscribe((record) => received.push(record)); + const first = owner.record({ + slotId: 'slot-a', + path: 'ssat', + rendered: true, + injected: false, + }); + const second = owner.record({ slotId: 'slot-b', path: 'auction', rendered: false }); + owner.enrich(first!, { injected: true, servedFrom: 'pbs-cache' }); + + expect(tasks).toHaveLength(1); + drain(); + expect(received.map(({ seq }) => seq)).toEqual([first!.seq, second!.seq]); + expect(received[0]).toEqual(expect.objectContaining({ injected: true })); + }); + + it('bounds current state and history and prunes navigation-owned slots', () => { + const { owner } = harness(); + for (let index = 0; index < 256; index += 1) { + expect( + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }) + ).toBeDefined(); + } + owner.record({ slotId: 'slot-over-capacity', path: 'auction', rendered: true }); + expect(Object.keys(owner.diagnostics.current())).toHaveLength(256); + owner.prune('slot-0'); + expect(owner.diagnostics.current()).not.toHaveProperty('slot-0'); + owner.record({ slotId: 'slot-after-prune', path: 'auction', rendered: true }); + + for (let index = 0; index < 10; index += 1) { + owner.record({ slotId: 'slot-1', path: 'gam-refresh', rendered: index % 2 === 0 }); + } + const history = owner.diagnostics.history(); + expect(history).toHaveLength(200); + expect(history[0]?.seq).toBeGreaterThan(1); + }); + + it('retains impression bookkeeping and refuses truth-weakening enrichment', () => { + const { owner } = harness(); + const record = owner.record({ + slotId: 'slot-a', + path: 'ssat', + rendered: true, + injected: true, + })!; + + const enriched = owner.enrich(record, { + rendered: false, + injected: false, + visible: true, + servedFrom: 'pbs-cache', + })!; + + expect(enriched).toEqual( + expect.objectContaining({ + at: record.at, + count: record.count, + seq: record.seq, + rendered: true, + injected: true, + visible: true, + servedFrom: 'pbs-cache', + }) + ); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + + it('drops the oldest of 201 pending records and cancels work on disposal', () => { + const { owner, tasks, drain } = harness(); + const listener = vi.fn(); + owner.diagnostics.subscribe(listener); + for (let index = 0; index < 201; index += 1) { + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + } + expect(tasks).toHaveLength(1); + drain(); + expect(listener).toHaveBeenCalledTimes(200); + expect(listener.mock.calls[0]?.[0].seq).toBe(2); + + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + expect(tasks).toHaveLength(1); + owner.dispose(); + owner.dispose(); + drain(); + expect(listener).toHaveBeenCalledTimes(200); + const late = owner.record({ slotId: 'late', path: 'auction', rendered: true }); + expect(Object.isFrozen(late)).toBe(true); + expect(owner.diagnostics.current()).toEqual({}); + expect(owner.diagnostics.history()).toEqual([]); + expect(owner.diagnostics.subscribe(() => undefined)).toBeTypeOf('function'); + expect(() => owner.diagnostics.subscribe(null as never)).toThrow(TypeError); + }); +}); From 21f50049774804eea0da402351623aff5b645a50 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:32:18 -0700 Subject: [PATCH 360/494] Fix render trace isolation test typing --- crates/trusted-server-js/lib/test/core/trace_runtime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index a5d7671ed..fef447bc5 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -28,7 +28,7 @@ function harness() { describe('render trace diagnostics runtime', () => { it('exposes one exact frozen read-only public surface with copied snapshots', () => { const { owner } = harness(); - const target = window as typeof window & { tsjs?: Record }; + const target = window as unknown as { tsjs?: Record }; const existingApi = (target.tsjs = {}); const event = vi.fn(); window.addEventListener('tsjs:adRendered', event); From f4814a5ce3a06d7825dbf63c9ed8c450f256dec5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:34:23 -0700 Subject: [PATCH 361/494] Publish terminal render diagnostics --- .../lib/src/services/render.ts | 28 +++++++++ .../lib/test/services/render.test.ts | 57 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 9d60befb3..acce274d8 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -637,9 +637,18 @@ export interface RenderAttemptOptions { readonly prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; readonly reservations: ReservationService; readonly parentAttemptId?: string; + readonly publishDiagnostics?: (observation: RenderAttemptDiagnosticsObservation) => unknown; readonly scheduler?: RenderScheduler; } +export interface RenderAttemptDiagnosticsObservation { + readonly kind: 'render_attempt'; + readonly attemptId: string; + readonly slotId: string; + readonly state: 'accepted' | 'no_bid' | 'failed' | 'cancelled'; + readonly outcome: RenderOutcome; +} + export type RenderAttemptCreationResult = | Readonly<{ ok: true; value: RenderAttempt }> | Readonly<{ @@ -1189,6 +1198,9 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let parentAttemptId: string | undefined; let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; let reservations: ReservationService; + let publishDiagnostics: + | ((observation: RenderAttemptDiagnosticsObservation) => unknown) + | undefined; let consumeClaimMethod: ReservationService['consumeClaim']; let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; let ownerDisposeMethod: RenderAttemptScope['dispose']; @@ -1229,6 +1241,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp parentAttemptId = options.parentAttemptId; prepareRenderSource = options.prepareRenderSource; reservations = options.reservations; + publishDiagnostics = options.publishDiagnostics; if (!isReservationService(reservations)) { return rejectConstruction('invalid_attempt'); } @@ -1257,6 +1270,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp typeof ownerPrepareWinnerMethod !== 'function' || typeof prepareRenderSource !== 'function' || typeof consumeClaimMethod !== 'function' || + (publishDiagnostics !== undefined && typeof publishDiagnostics !== 'function') || (parentAttemptId !== undefined && (!validAttemptId(parentAttemptId) || parentAttemptId === id)) ) { @@ -1540,6 +1554,20 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp settlingInternally = false; } } + if (publishDiagnostics) { + const observation = frozen({ + kind: 'render_attempt', + attemptId: id, + slotId: slot, + state: terminal.outcome, + outcome: terminal, + }); + try { + Reflect.apply(publishDiagnostics, undefined, [observation]); + } catch { + // Diagnostics publication cannot change terminal render authority. + } + } notify(terminal); return true; }; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 1c0d71bce..6caccf0b8 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -26,6 +26,8 @@ import { type DirectAdmIframeConstructor, type DirectAdmIframeHandle, type RenderAttempt, + type RenderAttemptDiagnosticsObservation, + type RenderAttemptSnapshot, type RenderAttemptState, type SlotOperation, type SlotOperationOptions, @@ -337,6 +339,9 @@ function attempt( prepareRenderSource: options.prepareRenderSource ?? prepareRenderSource, reservations: reservationService, ...(options.parentAttemptId === undefined ? {} : { parentAttemptId: options.parentAttemptId }), + ...(options.publishDiagnostics === undefined + ? {} + : { publishDiagnostics: options.publishDiagnostics }), ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), }); expect(result).toMatchObject({ ok: true }); @@ -4541,6 +4546,58 @@ describe('committed artifact ownership', () => { }); }); +describe('RenderAttempt diagnostics producer', () => { + it('publishes one frozen terminal observation only after accepted artifact state commits', () => { + const artifacts = createCommittedArtifactStore(); + const attemptReference: { current?: RenderAttempt } = {}; + const snapshots: RenderAttemptSnapshot[] = []; + const publishDiagnostics = vi.fn((observation: RenderAttemptDiagnosticsObservation) => { + expect(Object.isFrozen(observation)).toBe(true); + expect(Object.isFrozen(observation.outcome)).toBe(true); + snapshots.push(attemptReference.current!.snapshot()); + throw new Error('fictional diagnostics failure'); + }); + const renderAttempt = attempt(owner(), { artifacts, publishDiagnostics }); + attemptReference.current = renderAttempt; + expect(renderAttempt.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(renderAttempt.beginDirect()).toBe(true); + const committed = artifact(renderAttempt); + expect(renderAttempt.beginAdm(committed)).toBe(true); + + expect(renderAttempt.accept()).toBe(true); + + expect(artifacts.current(renderAttempt.slot)).toBe(committed); + expect(snapshots).toEqual([ + expect.objectContaining({ state: 'accepted', outcome: { outcome: 'accepted' } }), + ]); + expect(publishDiagnostics).toHaveBeenCalledOnce(); + expect(publishDiagnostics).toHaveBeenCalledWith({ + kind: 'render_attempt', + attemptId: renderAttempt.id, + slotId: renderAttempt.slot, + state: 'accepted', + outcome: { outcome: 'accepted' }, + }); + }); + + it('publishes terminal failure after the lifecycle state commit and never republishes', () => { + const attemptReference: { current?: RenderAttempt } = {}; + const observedStates: RenderAttemptState[] = []; + const publishDiagnostics = vi.fn(() => { + observedStates.push(attemptReference.current!.snapshot().state); + return false; + }); + const renderAttempt = attempt(owner(), { publishDiagnostics }); + attemptReference.current = renderAttempt; + + expect(renderAttempt.fail('runner_failed')).toBe(true); + expect(renderAttempt.cancel('superseded')).toBe(false); + + expect(observedStates).toEqual(['failed']); + expect(publishDiagnostics).toHaveBeenCalledOnce(); + }); +}); + describe('SlotOperation result isolation', () => { it('rejects an unbranded structural primary before observing or starting fallback', () => { const createFallback = vi.fn(); From 2184738914976c9af66b168edf843cd59d6f1dda Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:40:33 -0700 Subject: [PATCH 362/494] Wire private runtime diagnostics --- .../lib/src/composition/browser.ts | 78 ++++++++++++++++++- .../lib/src/kernel/diagnostics.ts | 7 ++ .../lib/src/kernel/runtime.ts | 13 +++- .../lib/src/services/render.ts | 18 ++++- .../lib/test/composition/browser.test.ts | 61 ++++++++++++++- .../lib/test/kernel/diagnostics.test.ts | 19 +++++ .../lib/test/kernel/runtime.test.ts | 31 ++++++++ .../lib/test/services/render.test.ts | 3 + 8 files changed, 221 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 9257e4c3f..60e369886 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -20,7 +20,13 @@ import { } from '../adapters/prebid'; import { parseCacheFetchPolicyV1 } from '../core/config'; import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; -import type { BrowserAuctionProjectionV1, CreativeBootV1 } from '../core/types'; +import type { + BootManifestV1, + BrowserAuctionProjectionV1, + CreativeBootV1, + DiagnosticsBootV1, +} from '../core/types'; +import { createRenderTrace, type RenderTraceRuntimeOwner } from '../core/trace'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -55,6 +61,11 @@ import { } from '../integrations/prebid/module'; import { createPrebidStartup } from '../integrations/prebid/startup'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; +import { + createDiagnosticsBus, + type DiagnosticsBus, + type DiagnosticsObservation, +} from '../kernel/diagnostics'; import type { NavigationIdentityIssuerFactory, RenderAttemptScope, @@ -186,9 +197,8 @@ interface AcceptedBrowserBoot { readonly auctionProjection: object; readonly cachePolicy?: unknown; readonly creative: Readonly; - readonly manifest: { - readonly integrations: readonly { readonly id: string }[]; - }; + readonly diagnostics: Readonly; + readonly manifest: Readonly; } interface PreparedBrowserServices { @@ -277,6 +287,47 @@ export function createTestBrowserRuntimeComposition( const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; let creativeBoot: Readonly | undefined; + let diagnosticsBus: DiagnosticsBus | undefined; + let renderTrace: RenderTraceRuntimeOwner | undefined; + const consumeCoreObservation = (observation: DiagnosticsObservation): void => { + if ( + observation['kind'] !== 'render_attempt' || + typeof observation['slotId'] !== 'string' || + (observation['path'] !== 'auction' && observation['path'] !== 'ssat') || + typeof observation['rendered'] !== 'boolean' + ) { + return; + } + const state = observation['state']; + const terminal = observation['outcome']; + const terminalRecord = + typeof terminal === 'object' && terminal !== null + ? (terminal as Readonly>) + : undefined; + const attributableEmpty = + state === 'failed' && + terminalRecord?.['outcome'] === 'failed' && + terminalRecord['reason'] === 'gam_empty'; + if (state !== 'accepted' && !attributableEmpty) return; + if ((state === 'accepted') !== observation['rendered']) return; + const servedFrom = observation['servedFrom']; + if (servedFrom !== undefined && servedFrom !== 'inline' && servedFrom !== 'pbs-cache') return; + try { + renderTrace?.record({ + slotId: observation['slotId'], + path: observation['path'], + rendered: observation['rendered'], + ...(servedFrom === undefined ? {} : { servedFrom }), + }); + } catch { + // Render diagnostics never affect the already-committed attempt. + } + }; + const diagnosticsForPublish = (): Readonly => { + const trace = renderTrace; + if (!trace) throw new Error('Render diagnostics are unavailable'); + return Object.freeze({ renderTrace: trace.diagnostics }); + }; const defaultCreativeRuntime = typeof document === 'undefined' ? Object.freeze({ @@ -573,6 +624,7 @@ export function createTestBrowserRuntimeComposition( const runtime = createRuntime({ ...runtimeOptions, getBindings, + getDiagnosticsForPublish: diagnosticsForPublish, kernel: { addAdUnits: addProgrammaticAdUnits, diagnostics: runtimeOptions.kernel.diagnostics, @@ -590,6 +642,22 @@ export function createTestBrowserRuntimeComposition( parseProjection ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); + const preparedRenderTrace = createRenderTrace({ + onSubscriberError: (error) => log.warn('render diagnostics: subscriber failed', error), + }); + const preparedDiagnosticsBus = createDiagnosticsBus({ + manifest: boot.manifest, + onObservation: consumeCoreObservation, + onSubscriberError: (error) => log.warn('diagnostics bus: subscriber failed', error), + }); + renderTrace = preparedRenderTrace; + diagnosticsBus = preparedDiagnosticsBus; + context.onDispose(() => { + preparedDiagnosticsBus.dispose(); + preparedRenderTrace.dispose(); + if (diagnosticsBus === preparedDiagnosticsBus) diagnosticsBus = undefined; + if (renderTrace === preparedRenderTrace) renderTrace = undefined; + }); const reconciliation = typeof document === 'undefined' || typeof MutationObserver === 'undefined' ? undefined @@ -737,6 +805,7 @@ export function createTestBrowserRuntimeComposition( const source = parseBidRenderSourceV1(candidate, cachePolicy); return source ? Object.freeze(source) : undefined; }, + publishDiagnostics: preparedDiagnosticsBus.publish, reservations: reservationService, }); const batchCoordinator = createAuctionBatchService({ @@ -785,6 +854,7 @@ export function createTestBrowserRuntimeComposition( interfaces: Object.freeze({ adapters: composition.adapters, creative: creativeRuntime, + diagnostics: Object.freeze({ subscribe: preparedDiagnosticsBus.subscribe }), gpt: gptRuntime, prebid: prebidRuntime, ...services, diff --git a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts index 281e1f2c7..11fd03f9b 100644 --- a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts +++ b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts @@ -16,6 +16,8 @@ export interface DiagnosticsScheduler { export interface DiagnosticsBusOptions { readonly manifest: Readonly; + /** Closure-private core observer; never included in the returned bus facade. */ + readonly onObservation?: (observation: DiagnosticsObservation) => void; readonly onOverflow?: (droppedObservations: number) => void; readonly onSubscriberError?: (error: unknown) => void; readonly pendingCapacity?: number; @@ -170,6 +172,11 @@ export function createDiagnosticsBus(options: DiagnosticsBusOptions): Diagnostic return Object.freeze({ publish: (observation: DiagnosticsObservation): boolean => { if (disposed || !recursivelyFrozenRecord(observation)) return false; + try { + options.onObservation?.(observation); + } catch { + // Core diagnostics consumption cannot affect correctness publication. + } const captured = Object.freeze([...subscriptions.values()]); if (captured.length === 0) return true; if (pending.length >= pendingCapacity) { diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 869b0f30a..191aafe5e 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -83,6 +83,8 @@ export interface RuntimeOptions { readonly boot?: unknown; readonly now?: () => number; readonly getBindings?: (id: string) => IntegrationBindings; + /** Resolve the complete frozen namespace after every diagnostics module activates. */ + readonly getDiagnosticsForPublish?: () => Readonly; readonly prepareOwner?: (context: RuntimeOwnerPreparationContext) => void; readonly activateOwner?: (context: RuntimeOwnerActivationContext) => void; readonly activateCore?: (context: CoreActivationContext) => void; @@ -284,6 +286,15 @@ class RuntimeOwner implements Runtime { } private kernelFields(): Readonly> { + const diagnostics = + this.options.getDiagnosticsForPublish?.() ?? this.options.kernel.diagnostics; + if ( + (typeof diagnostics !== 'object' && typeof diagnostics !== 'function') || + diagnostics === null || + !Object.isFrozen(diagnostics) + ) { + throw new Error('Published diagnostics namespace must be frozen'); + } const fields: Record = {}; Object.defineProperties(fields, { version: { enumerable: true, value: '1.0.0' }, @@ -296,7 +307,7 @@ class RuntimeOwner implements Runtime { _registerIntegration: { enumerable: true, value: () => false }, addAdUnits: { enumerable: true, value: this.options.kernel.addAdUnits }, requestAds: { enumerable: true, value: this.options.kernel.requestAds }, - diagnostics: { enumerable: true, value: this.options.kernel.diagnostics }, + diagnostics: { enumerable: true, value: diagnostics }, _internal: { enumerable: false, value: Object.freeze({ state: 'kernel', releaseId: EMBEDDED_RELEASE_ID }), diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index acce274d8..417bff92e 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -641,10 +641,13 @@ export interface RenderAttemptOptions { readonly scheduler?: RenderScheduler; } -export interface RenderAttemptDiagnosticsObservation { +export interface RenderAttemptDiagnosticsObservation extends Readonly> { readonly kind: 'render_attempt'; readonly attemptId: string; readonly slotId: string; + readonly path: 'auction' | 'ssat'; + readonly rendered: boolean; + readonly servedFrom?: 'inline' | 'pbs-cache'; readonly state: 'accepted' | 'no_bid' | 'failed' | 'cancelled'; readonly outcome: RenderOutcome; } @@ -1199,8 +1202,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp let prepareRenderSource: (candidate: unknown) => ReservationRenderSource | undefined; let reservations: ReservationService; let publishDiagnostics: - | ((observation: RenderAttemptDiagnosticsObservation) => unknown) - | undefined; + ((observation: RenderAttemptDiagnosticsObservation) => unknown) | undefined; let consumeClaimMethod: ReservationService['consumeClaim']; let ownerIsCurrentMethod: RenderAttemptScope['isCurrent']; let ownerDisposeMethod: RenderAttemptScope['dispose']; @@ -1533,6 +1535,7 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp const settle = (terminal: RenderOutcome, disposeOwner: boolean): boolean => { if (outcome !== undefined) return false; + const terminalRenderSource = admittedRenderSource; outcome = terminal; state = terminalState(terminal); arrayPush(history, state); @@ -1555,10 +1558,19 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } } if (publishDiagnostics) { + const servedFrom = + terminal.outcome === 'accepted' && terminalRenderSource?.type === 'cache' + ? ('pbs-cache' as const) + : terminal.outcome === 'accepted' + ? ('inline' as const) + : undefined; const observation = frozen({ kind: 'render_attempt', attemptId: id, slotId: slot, + path: history.includes('waiting_for_gam_and_claim') ? 'ssat' : 'auction', + rendered: terminal.outcome === 'accepted', + ...(servedFrom === undefined ? {} : { servedFrom }), state: terminal.outcome, outcome: terminal, }); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 16b530e4d..c1e846e34 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -574,7 +574,16 @@ describe('browser composition', () => { composition.runtime.registerIntegration({ id: 'test', release: 'a'.repeat(64), - prepare: ({ onDispose }: { onDispose(callback: () => void): void }) => { + prepare: ({ + interfaces, + onDispose, + }: { + interfaces: Readonly>; + onDispose(callback: () => void): void; + }) => { + expect(Reflect.ownKeys(interfaces['diagnostics'] as object)).toEqual(['subscribe']); + expect(interfaces['diagnostics']).not.toHaveProperty('publish'); + expect(interfaces['diagnostics']).not.toHaveProperty('dispose'); onDispose(() => order.push('dispose-module')); return { activate: () => order.push('module') }; }, @@ -583,6 +592,26 @@ describe('browser composition', () => { await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); expect(order).toEqual(['bridge', 'gpt', 'module']); expect(composition.pucBridgeForTest()).toBeDefined(); + const diagnostics = ( + target as { + diagnostics?: { + renderTrace?: { + current(): Readonly>; + history(): readonly unknown[]; + subscribe(listener: (record: unknown) => void): () => void; + }; + }; + } + ).diagnostics; + expect(Object.isFrozen(diagnostics)).toBe(true); + expect(Reflect.ownKeys(diagnostics ?? {})).toEqual(['renderTrace']); + expect(Reflect.ownKeys(diagnostics?.renderTrace ?? {}).sort()).toEqual([ + 'current', + 'history', + 'subscribe', + ]); + expect(diagnostics).not.toHaveProperty('publish'); + expect(diagnostics).not.toHaveProperty('dispose'); composition.runtime.dispose(); expect(order).toEqual([ @@ -602,6 +631,8 @@ describe('browser composition', () => { ); expect(Object.isFrozen(composition)).toBe(true); expect(Object.isFrozen(composition.runtime)).toBe(true); + expect(diagnostics?.renderTrace?.current()).toEqual({}); + expect(diagnostics?.renderTrace?.history()).toEqual([]); }); it('starts slot listeners before post-commit GPT startup and disposes both listeners', async () => { @@ -1792,6 +1823,10 @@ describe('browser composition', () => { await expect(api.requestAds({ slots: ['server-slot'] })).resolves.toEqual({ slots: [{ slot: 'server-slot', path: 'primary', outcome: 'no_bid' }], }); + const diagnostics = target as { + diagnostics?: { renderTrace?: { history(): readonly unknown[] } }; + }; + expect(diagnostics.diagnostics?.renderTrace?.history()).toEqual([]); expect(requestConfigs).toEqual([{}]); expect(warn).toHaveBeenCalledExactlyOnceWith('auction context: contributor failed', { @@ -1973,6 +2008,30 @@ describe('browser composition', () => { { slot: 'programmatic-slot', path: 'primary', outcome: 'accepted' }, ], }); + const renderTrace = ( + target as { + diagnostics?: { + renderTrace?: { + current(): Readonly>>>; + history(): readonly Readonly>[]; + }; + }; + } + ).diagnostics?.renderTrace; + expect(renderTrace?.current()['programmatic-slot']).toEqual( + expect.objectContaining({ + slotId: 'programmatic-slot', + path: 'auction', + rendered: true, + servedFrom: 'inline', + count: 1, + }) + ); + expect(renderTrace?.history()).toHaveLength(1); + expect(Object.isFrozen(renderTrace?.history()[0])).toBe(true); + expect(target).not.toHaveProperty('renders'); + expect(target).not.toHaveProperty('renderLog'); + expect(target).not.toHaveProperty('renderSeq'); expect(requestBodies[0]).toEqual({ adUnits: [programmatic], config: { page: 'context' }, diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts index 0b02a85f8..db4dcbb90 100644 --- a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -151,4 +151,23 @@ describe('kernel diagnostics bus', () => { expect(read).not.toHaveBeenCalled(); bus.dispose(); }); + + it('commits to the private core observer before asynchronous module delivery', () => { + vi.useFakeTimers(); + const order: string[] = []; + const bus = createDiagnosticsBus({ + manifest: manifest(['observer']), + onObservation: () => { + order.push('core'); + throw new Error('fictional core observer failure'); + }, + }); + bus.subscribe('observer', () => order.push('module')); + + expect(bus.publish(observation(1))).toBe(true); + expect(order).toEqual(['core']); + vi.runOnlyPendingTimers(); + expect(order).toEqual(['core', 'module']); + bus.dispose(); + }); }); diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts index b1297ffce..9af56233e 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -126,6 +126,37 @@ describe('Runtime bootstrap owner', () => { ).toBe(false); }); + it('resolves the frozen diagnostics namespace only after core and module activation', async () => { + const target: Record = {}; + const diagnostics = Object.freeze({ renderTrace: Object.freeze({}) }); + let activated = false; + const getDiagnosticsForPublish = vi.fn(() => { + expect(activated).toBe(true); + return diagnostics; + }); + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + activateCore: () => { + activated = true; + }, + getDiagnosticsForPublish, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({ premature: true }), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(getDiagnosticsForPublish).toHaveBeenCalledOnce(); + expect(target['diagnostics']).toBe(diagnostics); + }); + it('prepares inert owner interfaces before module preparation and activates afterward', async () => { const order: string[] = []; let prepared = false; diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 6caccf0b8..028d0b47c 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -4575,6 +4575,9 @@ describe('RenderAttempt diagnostics producer', () => { kind: 'render_attempt', attemptId: renderAttempt.id, slotId: renderAttempt.slot, + path: 'auction', + rendered: true, + servedFrom: 'inline', state: 'accepted', outcome: { outcome: 'accepted' }, }); From 4da3000f3829a401144425046c8e1ef4748f8dda Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:44:37 -0700 Subject: [PATCH 363/494] Bound GPT diagnostics notifications --- .../src/integrations/gpt_diagnostics/api.ts | 154 +++++++++++++----- .../integrations/gpt_diagnostics/api.test.ts | 86 +++++++++- 2 files changed, 192 insertions(+), 48 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index ab869d322..ec69d4eb0 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -1,4 +1,5 @@ import type { GptDiagnosticsApi, GptDiagnosticsExportV1 } from '../../core/types'; +import { DiagnosticsSubscriberLimitError } from '../../core/trace'; import type { GptDiagnosticsBindingManager } from './binding'; import type { GptDiagnosticsStoreSnapshot } from './store'; @@ -27,10 +28,21 @@ interface ApiOptions { window?: ApiWindow | undefined; document?: Document | undefined; now?: (() => Date) | undefined; - schedule?: ((callback: () => void) => void) | undefined; + schedule?: ((callback: () => void) => () => void) | undefined; } type ApiListener = (snapshot: GptDiagnosticsExportV1) => void; +const MAX_API_SUBSCRIBERS = 32; + +interface PendingNotification { + readonly snapshot: GptDiagnosticsExportV1; + readonly subscriberIds: readonly number[]; +} + +function scheduleTask(callback: () => void): () => void { + const handle = globalThis.setTimeout(callback, 0); + return () => globalThis.clearTimeout(handle); +} /** Owns the public read-only diagnostics API and its source subscriptions. */ export class GptDiagnosticsApiController { @@ -42,11 +54,13 @@ export class GptDiagnosticsApiController { private readonly window: ApiWindow; private readonly document: Document; private readonly now: () => Date; - private readonly schedule: (callback: () => void) => void; - private readonly listeners = new Set(); + private readonly schedule: (callback: () => void) => () => void; + private readonly listeners = new Map(); private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; - private notificationScheduled = false; + private pending: PendingNotification | undefined; + private cancelScheduled: (() => void) | undefined; + private nextSubscriberId = 0; private destroyed = false; constructor( @@ -61,47 +75,65 @@ export class GptDiagnosticsApiController { this.window = options.window ?? (window as unknown as ApiWindow); this.document = options.document ?? document; this.now = options.now ?? (() => new Date()); - this.schedule = options.schedule ?? ((callback) => queueMicrotask(callback)); + this.schedule = options.schedule ?? scheduleTask; this.unsubscribeStore = this.store.subscribe(() => this.scheduleNotification()); this.unsubscribeBindings = this.bindings.subscribe(() => this.scheduleNotification()); - this.api = { + this.api = Object.freeze({ snapshot: () => this.snapshot(), export: () => this.download(), - subscribe: (listener) => this.subscribe(listener), + subscribe: (listener: ApiListener) => this.subscribe(listener), show: () => this.presentation.show(), hide: () => this.presentation.hide(), - }; + }); } snapshot(): GptDiagnosticsExportV1 { const store = this.store.snapshot(); - return { + const slots = Object.freeze( + store.slots.map((slot) => + Object.freeze({ + runtimeSlotNumber: slot.runtimeSlotNumber, + slotElementId: slot.slotElementId, + adUnitPath: slot.adUnitPath, + binding: Object.freeze({ ...this.bindings.exportBinding(slot.runtimeSlotNumber) }), + currentVisibilityPercentage: slot.currentVisibilityPercentage, + maximumVisibilityPercentage: slot.maximumVisibilityPercentage, + requests: Object.freeze( + slot.requests.map((cycle) => + Object.freeze({ + ...cycle, + durations: Object.freeze({ ...cycle.durations }), + size: cycle.size ? Object.freeze([...cycle.size]) : undefined, + }) + ) + ), + }) + ) + ); + const callbackIssues = Object.freeze( + store.callbackIssues.map((issue) => Object.freeze({ ...issue })) + ); + const coverage = Object.freeze( + Object.fromEntries( + Object.entries(store.coverage).map(([kind, counters]) => [ + kind, + Object.freeze({ ...counters }), + ]) + ) + ) as GptDiagnosticsExportV1['coverage']; + return Object.freeze({ version: 1, capturedAt: this.now().toISOString(), - page: { + page: Object.freeze({ origin: this.window.location.origin, pathname: this.window.location.pathname, - }, - slots: store.slots.map((slot) => ({ - runtimeSlotNumber: slot.runtimeSlotNumber, - slotElementId: slot.slotElementId, - adUnitPath: slot.adUnitPath, - binding: this.bindings.exportBinding(slot.runtimeSlotNumber), - currentVisibilityPercentage: slot.currentVisibilityPercentage, - maximumVisibilityPercentage: slot.maximumVisibilityPercentage, - requests: slot.requests.map((cycle) => ({ - ...cycle, - durations: { ...cycle.durations }, - size: cycle.size ? [...cycle.size] : undefined, - })), - })), - callbackIssues: store.callbackIssues.map((issue) => ({ ...issue })), - coverage: Object.fromEntries( - Object.entries(store.coverage).map(([kind, counters]) => [kind, { ...counters }]) - ) as GptDiagnosticsExportV1['coverage'], - metadata: { ...store.metadata }, - }; + }), + slots, + callbackIssues, + coverage, + metadata: Object.freeze({ ...store.metadata }), + }) as GptDiagnosticsExportV1; } destroy(): void { @@ -109,13 +141,30 @@ export class GptDiagnosticsApiController { this.destroyed = true; this.unsubscribeStore(); this.unsubscribeBindings(); + try { + this.cancelScheduled?.(); + } catch { + // The destroyed latch suppresses a hostile late scheduler callback. + } + this.cancelScheduled = undefined; + this.pending = undefined; this.listeners.clear(); } private subscribe(listener: ApiListener): () => void { + if (typeof listener !== 'function') throw new TypeError('Diagnostics listener must be callable'); if (this.destroyed) return () => undefined; - this.listeners.add(listener); - return () => this.listeners.delete(listener); + if (this.listeners.size >= MAX_API_SUBSCRIBERS) { + throw new DiagnosticsSubscriberLimitError('gpt'); + } + const id = (this.nextSubscriberId += 1); + this.listeners.set(id, listener); + let active = true; + return () => { + if (!active) return; + active = false; + this.listeners.delete(id); + }; } private download(): void { @@ -139,19 +188,34 @@ export class GptDiagnosticsApiController { } private scheduleNotification(): void { - if (this.destroyed || this.notificationScheduled) return; - this.notificationScheduled = true; - this.schedule(() => { - this.notificationScheduled = false; - if (this.destroyed) return; - - for (const listener of this.listeners) { - try { - listener(this.snapshot()); - } catch { - // One API subscriber must not block the rest. - } - } + if (this.destroyed || this.listeners.size === 0) return; + const pending = Object.freeze({ + snapshot: this.snapshot(), + subscriberIds: Object.freeze([...this.listeners.keys()]), }); + this.pending = pending; + if (this.cancelScheduled) return; + try { + const cancel = this.schedule(() => { + this.cancelScheduled = undefined; + const notification = this.pending; + this.pending = undefined; + if (this.destroyed || !notification) return; + for (const id of notification.subscriberIds) { + const listener = this.listeners.get(id); + if (!listener) continue; + try { + listener(notification.snapshot); + } catch { + // One API subscriber must not block the rest. + } + } + }); + if (typeof cancel !== 'function') throw new TypeError('Invalid diagnostics scheduler'); + if (!this.destroyed && this.pending) this.cancelScheduled = cancel; + } catch { + this.cancelScheduled = undefined; + this.pending = undefined; + } } } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index b3350de51..70f43243f 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { GptDiagnosticsBinding } from '../../../src/core/types'; +import { DiagnosticsSubscriberLimitError } from '../../../src/core/trace'; import { GptDiagnosticsApiController } from '../../../src/integrations/gpt_diagnostics/api'; import { GptDiagnosticsStore } from '../../../src/integrations/gpt_diagnostics/store'; @@ -37,6 +38,16 @@ function readBlob(blob: Blob): Promise { }); } +function scheduleInto(tasks: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + tasks.push(callback); + return () => { + const index = tasks.indexOf(callback); + if (index >= 0) tasks.splice(index, 1); + }; + }; +} + beforeEach(() => { vi.restoreAllMocks(); window.history.replaceState({}, '', '/article?private=value#fragment'); @@ -98,6 +109,11 @@ describe('GptDiagnosticsApiController', () => { const second = controller.api.snapshot(); expect(second).not.toBe(snapshot); expect(second.slots).not.toBe(snapshot.slots); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.page)).toBe(true); + expect(Object.isFrozen(snapshot.slots)).toBe(true); + expect(Object.isFrozen(snapshot.slots[0]?.requests)).toBe(true); + expect(Object.isFrozen(snapshot.slots[0]?.requests[0]?.durations)).toBe(true); }); it('coalesces store and binding updates and isolates subscribers', () => { @@ -113,7 +129,7 @@ describe('GptDiagnosticsApiController', () => { { show: vi.fn(), hide: vi.fn() }, { now: () => new Date('2026-07-28T00:00:00.000Z'), - schedule: (callback) => scheduled.push(callback), + schedule: scheduleInto(scheduled), } ); controller.api.subscribe(() => { @@ -138,6 +154,66 @@ describe('GptDiagnosticsApiController', () => { expect(listener).toHaveBeenCalledTimes(1); }); + it('captures subscriber membership per commit and coalesces to the latest snapshot', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const bindings = new FakeBindings(); + const controller = new GptDiagnosticsApiController( + store, + bindings, + { show: vi.fn(), hide: vi.fn() }, + { + now: () => new Date('2026-07-28T00:00:00.000Z'), + schedule: scheduleInto(scheduled), + } + ); + const first = vi.fn(); + const second = vi.fn(); + const releaseFirst = controller.api.subscribe(first); + const observedSlot = fakeSlot(); + + store.recordSlotRequested(observedSlot); + controller.api.subscribe(second); + releaseFirst(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(observedSlot, 10); + store.recordSlotVisibilityChanged(observedSlot, 20); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(second).toHaveBeenCalledOnce(); + expect(second.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + slots: [expect.objectContaining({ currentVisibilityPercentage: 20 })], + }) + ); + }); + + it('validates callability before enforcing the shared 32-subscriber cap', () => { + const controller = new GptDiagnosticsApiController( + new GptDiagnosticsStore({ now: () => 1 }), + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() } + ); + const releases = Array.from({ length: 32 }, () => + controller.api.subscribe(() => undefined) + ); + + expect(() => controller.api.subscribe(null as never)).toThrow(TypeError); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + DiagnosticsSubscriberLimitError + ); + expect(() => controller.api.subscribe(() => undefined)).toThrow( + expect.objectContaining({ code: 'subscriber_capacity', surface: 'gpt' }) + ); + releases[0]?.(); + releases[0]?.(); + expect(controller.api.subscribe(() => undefined)).toEqual(expect.any(Function)); + }); + it('delegates show and hide without mutating diagnostics data', () => { const store = new GptDiagnosticsStore({ now: () => 1 }); const presentation = { show: vi.fn(), hide: vi.fn() }; @@ -206,13 +282,17 @@ describe('GptDiagnosticsApiController', () => { store, bindings, { show: vi.fn(), hide: vi.fn() }, - { schedule: (callback) => scheduled.push(callback) } + { schedule: scheduleInto(scheduled) } ); const listener = vi.fn(); controller.api.subscribe(listener); - controller.destroy(); store.recordSlotRequested(fakeSlot()); + expect(scheduled).toHaveLength(1); + + controller.destroy(); + while (scheduled.length > 0) scheduled.shift()?.(); + store.recordSlotVisibilityChanged(fakeSlot(), 10); bindings.emit(); expect(scheduled).toEqual([]); From 40c469691b5d62b720aecd1826cc73a1923277ca Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:46:29 -0700 Subject: [PATCH 364/494] Harden publisher GPT call provenance --- .../lib/src/adapters/googletag.ts | 208 +++++++++++++----- .../lib/test/adapters/googletag.test.ts | 202 +++++++++++++++++ .../lib/test/services/targeting.test.ts | 33 +++ 3 files changed, 391 insertions(+), 52 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index d4c00efd6..dc3336af4 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -89,6 +89,12 @@ export interface GoogletagTargetingObservation { readonly isCurrent: () => boolean; } +/** Reversible bookkeeping prepared before one publisher GPT call. */ +export interface GoogletagPublisherCallAdmission { + readonly commit: () => void; + readonly rollback: () => void; +} + /** One publisher-originated GPT call observed outside Trusted Server operations. */ export interface GoogletagPublisherCallObserver { readonly defineSlot?: ( @@ -97,12 +103,16 @@ export interface GoogletagPublisherCallObserver { readonly destroySlots?: (call: Readonly) => void; readonly display?: ( call: Readonly - ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; - readonly refresh?: ( - call: Readonly ) => - | Readonly<{ action: 'forward' }> - | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ action: 'suppress' }>; + readonly refresh?: (call: Readonly) => + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ + action: 'replace'; + slots: readonly object[]; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }>; } @@ -394,10 +404,15 @@ function createFacade( isOperationCurrent: () => boolean, isBindingCurrent: () => boolean, initialLoadDisabled: (service: object) => boolean, - targetingWrites: WeakMap, targetingObservations: WeakMap, bindingToken: object, - markFirstDisplay: () => void + markFirstDisplay: () => void, + invokeFacadeCall: ( + callable: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ) => unknown, + consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean ): Readonly { const member = (external: object, key: PropertyKey): ((...args: unknown[]) => unknown) => { if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); @@ -412,7 +427,7 @@ function createFacade( const call = (external: object, key: PropertyKey, argumentsList: readonly unknown[]): unknown => { const callable = member(external, key); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); - const result = Reflect.apply(callable, external, argumentsList); + const result = invokeFacadeCall(callable, external, argumentsList); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return result; }; @@ -423,16 +438,6 @@ function createFacade( return result; }; const service = (): object => asObject(call(binding.binding, 'pubads', [])); - const withTargetingWrite = (slot: object, callback: () => unknown): unknown => { - const depth = weakMapValue(targetingWrites, slot) ?? 0; - setWeakMapValue(targetingWrites, slot, depth + 1); - try { - return callback(); - } finally { - if (depth === 0) deleteWeakMapValue(targetingWrites, slot); - else setWeakMapValue(targetingWrites, slot, depth); - } - }; const replaceObservedMethod = ( slot: object, key: 'clearTargeting' | 'setTargeting', @@ -443,13 +448,14 @@ function createFacade( let descriptor: PropertyDescriptor | undefined; let defineAttempted = false; const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { - if ((weakMapValue(targetingWrites, slot) ?? 0) === 0) { - try { - const mutationKey = typeof arguments_[0] === 'string' ? arguments_[0] : undefined; - observer.beforePublisherMutation(slot, mutationKey); - } catch { - // Bookkeeping must not change publisher call arguments, order, return, or throw. - } + if (consumeFacadeCall(wrapper)) { + return Reflect.apply(original, this, arguments_); + } + try { + const mutationKey = typeof arguments_[0] === 'string' ? arguments_[0] : undefined; + observer.beforePublisherMutation(slot, mutationKey); + } catch { + // Bookkeeping must not change publisher call arguments, order, return, or throw. } return Reflect.apply(original, this, arguments_); }; @@ -507,13 +513,13 @@ function createFacade( return Object.freeze({ bindingToken: (): object => bindingToken, clearTargeting: (slot: object, key?: string): unknown => - withTargetingWrite(slot, () => call(slot, 'clearTargeting', key === undefined ? [] : [key])), + call(slot, 'clearTargeting', key === undefined ? [] : [key]), display: (slot: string | object): unknown => { const display = member(binding.binding, 'display'); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); markFirstDisplay(); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); - const result = Reflect.apply(display, binding.binding, [slot]); + const result = invokeFacadeCall(display, binding.binding, [slot]); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); return result; }, @@ -645,9 +651,7 @@ function createFacade( }); }, setTargeting: (slot: object, key: string, value: string | readonly string[]): unknown => - withTargetingWrite(slot, () => - call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]) - ), + call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]), slots: (): readonly object[] => { const currentSlots = call(service(), 'getSlots', []); if ( @@ -822,15 +826,36 @@ export function createBrowserGoogletagAdapter( const live = new Set>(); const effects = new Set<() => void>(); let armedBindings = new WeakSet(); - const targetingWrites = new WeakMap(); const targetingObservations = new WeakMap(); + const facadeCalls = new WeakMap<(...arguments_: unknown[]) => unknown, number>(); const bindingTokens = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; - let trustedCallDepth = 0; + + const invokeFacadeCall = ( + callable: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[] + ): unknown => { + const depth = weakMapValue(facadeCalls, callable) ?? 0; + setWeakMapValue(facadeCalls, callable, depth + 1); + try { + return Reflect.apply(callable, receiver, arguments_); + } finally { + if (depth === 0) deleteWeakMapValue(facadeCalls, callable); + else setWeakMapValue(facadeCalls, callable, depth); + } + }; + const consumeFacadeCall = (callable: (...arguments_: unknown[]) => unknown): boolean => { + const depth = weakMapValue(facadeCalls, callable) ?? 0; + if (depth === 0) return false; + if (depth === 1) deleteWeakMapValue(facadeCalls, callable); + else setWeakMapValue(facadeCalls, callable, depth - 1); + return true; + }; const markFirstDisplay = (): void => { if (firstDisplayObserved) return; @@ -1519,10 +1544,11 @@ export function createBrowserGoogletagAdapter( const tracker = ensureInitialLoadTracking(binding, service); return tracker?.disabled === true; }, - targetingWrites, targetingObservations, bindingToken, - markFirstDisplay + markFirstDisplay, + invokeFacadeCall, + consumeFacadeCall ); try { if (disposed) { @@ -1562,13 +1588,7 @@ export function createBrowserGoogletagAdapter( return; } try { - trustedCallDepth += 1; - let value: unknown; - try { - value = operation.command(facade); - } finally { - trustedCallDepth -= 1; - } + const value = operation.command(facade); if (operation.settled) return; if (disposed) { fail(operation, 'operation_disposed'); @@ -1908,6 +1928,66 @@ export function createBrowserGoogletagAdapter( !disposed && readTarget(target) === currentBindingObject && Reflect.apply(current.value.pubads, currentBindingObject, []) === serviceObject; + const safelyCurrent = (): boolean => { + try { + return stillCurrent(); + } catch { + return false; + } + }; + const publisherAdmission = (decision: unknown): GoogletagPublisherCallAdmission | undefined => { + if ((typeof decision !== 'object' || decision === null) && typeof decision !== 'function') { + return undefined; + } + const candidate = safeMember(decision as object, 'admission'); + if ( + (typeof candidate !== 'object' || candidate === null) && + typeof candidate !== 'function' + ) { + return undefined; + } + const commit = safeMember(candidate as object, 'commit'); + const rollback = safeMember(candidate as object, 'rollback'); + if (typeof commit !== 'function' || typeof rollback !== 'function') return undefined; + return Object.freeze({ + commit: (): void => { + Reflect.apply(commit, candidate, []); + }, + rollback: (): void => { + Reflect.apply(rollback, candidate, []); + }, + }); + }; + const commitAdmission = (admission: GoogletagPublisherCallAdmission | undefined): void => { + try { + admission?.commit(); + } catch { + // Post-native bookkeeping cannot alter the publisher return value. + } + }; + const rollbackAdmission = (admission: GoogletagPublisherCallAdmission | undefined): void => { + try { + admission?.rollback(); + } catch { + // Rollback cannot replace the exact publisher-native failure. + } + }; + const callWithAdmission = ( + original: (...arguments_: unknown[]) => unknown, + receiver: unknown, + arguments_: readonly unknown[], + admission: GoogletagPublisherCallAdmission | undefined + ): unknown => { + let result: unknown; + try { + result = Reflect.apply(original, receiver, arguments_); + } catch (error) { + rollbackAdmission(admission); + throw error; + } + commitAdmission(admission); + return result; + }; const objectSlots = (candidate: unknown): readonly object[] | undefined => { if ( !Array.isArray(candidate) || @@ -1942,7 +2022,10 @@ export function createBrowserGoogletagAdapter( if (typeof original !== 'function') return; const callable = original as (...arguments_: unknown[]) => unknown; const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { - if (trustedCallDepth > 0 || !stillCurrent()) { + if (consumeFacadeCall(wrapper)) { + return Reflect.apply(callable, this, arguments_); + } + if (!safelyCurrent()) { return Reflect.apply(callable, this, arguments_); } return mediate(callable, this, arguments_); @@ -1979,17 +2062,24 @@ export function createBrowserGoogletagAdapter( }); install(currentBindingObject, 'display', (original, receiver, arguments_) => { if (displayObserver && arguments_.length === 1) { + let decision: ReturnType>; try { - const decision = displayObserver( + decision = displayObserver( Object.freeze({ target: arguments_[0], initialLoadDisabled: tracker?.disabled === true, }) ); - if (decision?.action === 'suppress') return undefined; } catch { // Observer failure must leave the publisher call native. + return Reflect.apply(original, receiver, arguments_); } + const admission = publisherAdmission(decision); + if (decision?.action === 'suppress') { + rollbackAdmission(admission); + return undefined; + } + return callWithAdmission(original, receiver, arguments_, admission); } return Reflect.apply(original, receiver, arguments_); }); @@ -1998,20 +2088,34 @@ export function createBrowserGoogletagAdapter( const requested = arguments_[0] === undefined ? undefined : objectSlots(arguments_[0]); const effective = requested ?? (arguments_[0] === undefined ? allSlots() : undefined); if (effective) { + let decision: ReturnType>; try { - const decision = refreshObserver( + decision = refreshObserver( Object.freeze({ requestedSlots: requested, slots: effective }) ); - if (decision?.action === 'suppress') return undefined; - if (decision?.action === 'replace') { - const replacement = objectSlots(decision.slots); - if (replacement) { - return Reflect.apply(original, receiver, [replacement, ...arguments_.slice(1)]); - } - } } catch { // Observer failure must leave the publisher call native. + return Reflect.apply(original, receiver, arguments_); + } + const admission = publisherAdmission(decision); + if (decision?.action === 'suppress') { + rollbackAdmission(admission); + return undefined; + } + if (decision?.action === 'replace') { + const replacement = objectSlots(decision.slots); + if (replacement) { + return callWithAdmission( + original, + receiver, + [replacement, ...arguments_.slice(1)], + admission + ); + } + rollbackAdmission(admission); + return Reflect.apply(original, receiver, arguments_); } + return callWithAdmission(original, receiver, arguments_, admission); } } return Reflect.apply(original, receiver, arguments_); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 82cdd3edd..90fefe4e0 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -2010,6 +2010,208 @@ describe('browser googletag adapter readiness', () => { }); }); + it('observes publisher GPT calls reentered by one facade-driven native display', async () => { + const ready = createReadyGoogletag(); + const slot = Object.freeze({ id: 'nested-publisher-slot' }); + ready.pubads.getSlots.mockReturnValue([slot]); + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + ready.display.mockImplementation(() => { + ready.pubads.refresh([slot], { changeCorrelator: true }); + ready.googletag.defineSlot('/publisher', [300, 250], 'nested-slot'); + ready.googletag.destroySlots([slot]); + }); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + destroySlots: vi.fn(), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect(adapter.run((gpt) => gpt.display('trusted-slot')).result).resolves.toBeUndefined(); + + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).toHaveBeenCalledExactlyOnceWith({ + requestedSlots: [slot], + slots: [slot], + }); + expect(observer.defineSlot).toHaveBeenCalledExactlyOnceWith({ + adUnitPath: '/publisher', + elementId: 'nested-slot', + initialLoadDisabled: false, + sizes: [300, 250], + }); + expect(observer.destroySlots).toHaveBeenCalledExactlyOnceWith({ slots: [slot] }); + }); + + it('observes a publisher wrapper call made inside a TS command but outside a facade invocation', async () => { + const ready = createReadyGoogletag(); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls(observer); + + await expect( + adapter.run((gpt) => { + ready.googletag.defineSlot('/publisher', [300, 250], 'publisher-inside-command'); + gpt.display('trusted-slot'); + }).result + ).resolves.toBeUndefined(); + + expect(observer.defineSlot).toHaveBeenCalledExactlyOnceWith({ + adUnitPath: '/publisher', + elementId: 'publisher-inside-command', + initialLoadDisabled: false, + sizes: [300, 250], + }); + expect(observer.display).not.toHaveBeenCalled(); + }); + + it('commits publisher display and refresh admissions only after exact native returns', () => { + const ready = createReadyGoogletag(); + const slot = Object.freeze({ id: 'publisher-slot' }); + const receiver = Object.freeze({ publisher: true }); + const refreshOptions = Object.freeze({ changeCorrelator: false, publisher: 'exact' }); + const order: string[] = []; + const displayAdmission = Object.freeze({ + commit: vi.fn(() => order.push('commit:display')), + rollback: vi.fn(), + }); + const refreshAdmission = Object.freeze({ + commit: vi.fn(() => order.push('commit:refresh')), + rollback: vi.fn(), + }); + const nativeDisplay = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:display'); + return Object.freeze({ arguments_, receiver: this }); + }); + const nativeRefresh = vi.fn(function (this: unknown, ...arguments_: unknown[]) { + order.push('native:refresh'); + return Object.freeze({ arguments_, receiver: this }); + }); + ready.googletag.display = nativeDisplay; + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls({ + display: () => Object.freeze({ action: 'forward' as const, admission: displayAdmission }), + refresh: () => Object.freeze({ action: 'forward' as const, admission: refreshAdmission }), + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(display, receiver, ['slot'])).toEqual({ + arguments_: ['slot'], + receiver, + }); + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(Reflect.apply(refresh, receiver, [[slot], refreshOptions])).toEqual({ + arguments_: [[slot], refreshOptions], + receiver, + }); + + expect(order).toEqual(['native:display', 'commit:display', 'native:refresh', 'commit:refresh']); + expect(displayAdmission.rollback).not.toHaveBeenCalled(); + expect(refreshAdmission.rollback).not.toHaveBeenCalled(); + }); + + it('rolls back each unconsumed publisher admission on native throw and rethrows the exact error', () => { + const ready = createReadyGoogletag(); + const displayError = new Error('exact display failure'); + const refreshError = new Error('exact refresh failure'); + const displayAdmissions = [0, 1].map(() => + Object.freeze({ commit: vi.fn(), rollback: vi.fn() }) + ); + const refreshAdmission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + ready.googletag.display = vi.fn(() => { + throw displayError; + }); + ready.pubads.refresh = vi.fn(() => { + throw refreshError; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + let displayAttempt = 0; + adapter.observePublisherCalls({ + display: () => + Object.freeze({ + action: 'forward' as const, + admission: displayAdmissions[displayAttempt++]!, + }), + refresh: () => Object.freeze({ action: 'forward' as const, admission: refreshAdmission }), + }); + + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + expect(() => display('slot')).toThrow(displayError); + expect(() => display('slot')).toThrow(displayError); + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + expect(() => refresh(undefined, { changeCorrelator: true })).toThrow(refreshError); + + for (const admission of displayAdmissions) { + expect(admission.rollback).toHaveBeenCalledOnce(); + expect(admission.commit).not.toHaveBeenCalled(); + } + expect(refreshAdmission.rollback).toHaveBeenCalledOnce(); + expect(refreshAdmission.commit).not.toHaveBeenCalled(); + }); + + it.each(['pubads', 'target_getter'] as const)( + 'fails open to captured publisher natives when %s identity probing throws', + (failure) => { + const ready = createReadyGoogletag(); + const target: { googletag?: unknown } = { googletag: ready.googletag }; + const slot = Object.freeze({ id: 'publisher-slot' }); + const nativeDefine = vi.fn(() => 'defined'); + const nativeDisplay = vi.fn(() => 'displayed'); + const nativeRefresh = vi.fn(() => 'refreshed'); + const nativeDestroy = vi.fn(() => true); + ready.googletag.defineSlot = nativeDefine; + ready.googletag.display = nativeDisplay; + ready.googletag.destroySlots = nativeDestroy; + ready.pubads.refresh = nativeRefresh; + ready.pubads.getSlots.mockReturnValue([slot]); + const adapter = createBrowserGoogletagAdapter(target); + const observer = { + defineSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + destroySlots: vi.fn(), + display: vi.fn(() => Object.freeze({ action: 'forward' as const })), + refresh: vi.fn(() => Object.freeze({ action: 'forward' as const })), + }; + adapter.observePublisherCalls(observer); + const define = ready.googletag.defineSlot as (...arguments_: unknown[]) => unknown; + const display = ready.googletag.display as (...arguments_: unknown[]) => unknown; + const refresh = ready.pubads.refresh as (...arguments_: unknown[]) => unknown; + const destroy = ready.googletag.destroySlots as (...arguments_: unknown[]) => unknown; + const identityError = new Error(`throwing ${failure}`); + if (failure === 'pubads') { + ready.googletag.pubads.mockImplementation(() => { + throw identityError; + }); + } else { + Object.defineProperty(target, 'googletag', { + configurable: true, + get: () => { + throw identityError; + }, + }); + } + + expect(define('/publisher', [300, 250], 'slot')).toBe('defined'); + expect(display('slot')).toBe('displayed'); + expect(refresh([slot], { changeCorrelator: false })).toBe('refreshed'); + expect(destroy([slot])).toBe(true); + expect(nativeDefine).toHaveBeenCalledOnce(); + expect(nativeDisplay).toHaveBeenCalledOnce(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + expect(nativeDestroy).toHaveBeenCalledOnce(); + expect(observer.defineSlot).not.toHaveBeenCalled(); + expect(observer.display).not.toHaveBeenCalled(); + expect(observer.refresh).not.toHaveBeenCalled(); + expect(observer.destroySlots).not.toHaveBeenCalled(); + } + ); + it('mediates only explicit publisher decisions and preserves receiver, arguments, return, throw, and order', () => { const ready = createReadyGoogletag({ initialLoadDisabled: true }); const handoffSlot = Object.freeze({ id: 'handoff' }); diff --git a/crates/trusted-server-js/lib/test/services/targeting.test.ts b/crates/trusted-server-js/lib/test/services/targeting.test.ts index c637d41bc..022abf5d3 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -206,6 +206,39 @@ describe('owner-aware targeting journal', () => { expect(values.size).toBe(0); }); + it('invalidates a TS journal when its captured native setter reenters a same-value publisher set', async () => { + const values = new Map([['key', ['publisher']]]); + let reentered = false; + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => Object.freeze([...(values.get(key) ?? [])])), + setTargeting: vi.fn((key: string, value: string | readonly string[]) => { + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + if (reentered) return; + reentered = true; + slot.setTargeting(key, value); + }), + }; + const adapter = adapterForTargetingSlot(slot); + const service = createTargetingService(); + await expect(service.observePublisherMutations(slot, adapter).result).resolves.toBeUndefined(); + + const frame = await adapter.run((gpt) => + service.own(slot, 'key', 'trusted', 'owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + frame?.release(); + + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + it.each(['same_set', 'different_set', 'per_key_clear', 'clear_all'] as const)( 'invalidates after publisher wrapper replacement for %s without calling that replacement on release', async (mutation) => { From fac4c500c81be544d61f87d9a5f830dc266b508d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:47:10 -0700 Subject: [PATCH 365/494] Format GPT diagnostics notifications --- .../lib/src/integrations/gpt_diagnostics/api.ts | 3 ++- .../lib/test/integrations/gpt_diagnostics/api.test.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index ec69d4eb0..5b1bbcb3c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -152,7 +152,8 @@ export class GptDiagnosticsApiController { } private subscribe(listener: ApiListener): () => void { - if (typeof listener !== 'function') throw new TypeError('Diagnostics listener must be callable'); + if (typeof listener !== 'function') + throw new TypeError('Diagnostics listener must be callable'); if (this.destroyed) return () => undefined; if (this.listeners.size >= MAX_API_SUBSCRIBERS) { throw new DiagnosticsSubscriberLimitError('gpt'); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index 70f43243f..b6d90f4b4 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -198,9 +198,7 @@ describe('GptDiagnosticsApiController', () => { new FakeBindings(), { show: vi.fn(), hide: vi.fn() } ); - const releases = Array.from({ length: 32 }, () => - controller.api.subscribe(() => undefined) - ); + const releases = Array.from({ length: 32 }, () => controller.api.subscribe(() => undefined)); expect(() => controller.api.subscribe(null as never)).toThrow(TypeError); expect(() => controller.api.subscribe(() => undefined)).toThrow( From 899e08e54a8eacee77cbb4edb3952ac6772fbb01 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:55:24 -0700 Subject: [PATCH 366/494] Add bounded GPT diagnostics fact capture --- .../lib/src/adapters/googletag.ts | 125 ++++++++++- .../src/integrations/gpt_diagnostics/facts.ts | 209 ++++++++++++++++++ .../lib/test/adapters/googletag.test.ts | 64 ++++++ .../gpt_diagnostics/facts.test.ts | 104 +++++++++ 4 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index dc3336af4..4585a25eb 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -183,6 +183,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; + observeDiagnostics?(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; run( command: (googletag: Readonly) => T, @@ -192,6 +193,26 @@ export interface GoogletagAdapter { dispose(): void; } +export type GoogletagDiagnosticsEventName = + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + +export interface GoogletagDiagnosticsFact { + readonly kind: GoogletagDiagnosticsEventName; + readonly slot: object; + readonly isEmpty?: boolean; + readonly size?: readonly [number, number]; + readonly isBackfill?: boolean; + readonly slotContentChanged?: boolean; + readonly inViewPercentage?: number; +} + +export type GoogletagDiagnosticsObserver = (fact: Readonly) => void; + /** Browser surface owned by the concrete GPT adapter. */ export interface GoogletagGlobalTarget { googletag?: unknown; @@ -412,7 +433,8 @@ function createFacade( receiver: unknown, arguments_: readonly unknown[] ) => unknown, - consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean + consumeFacadeCall: (callable: (...arguments_: unknown[]) => unknown) => boolean, + publishDiagnostics: (eventType: string, event: unknown) => void ): Readonly { const member = (external: object, key: PropertyKey): ((...args: unknown[]) => unknown) => { if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); @@ -674,6 +696,7 @@ function createFacade( } catch { // Publisher and service callbacks cannot escape the GPT boundary. } + publishDiagnostics(eventType, event); }; let attempted = false; const rollback = (): void => { @@ -831,10 +854,83 @@ export function createBrowserGoogletagAdapter( const bindingTokens = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); + let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; + const diagnosticFact = ( + eventType: string, + event: unknown + ): Readonly | undefined => { + try { + if ((typeof event !== 'object' || event === null) && typeof event !== 'function') { + return undefined; + } + const slot = safeMember(event as object, 'slot'); + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { + return undefined; + } + const base = { kind: eventType, slot: slot as object }; + switch (eventType) { + case 'slotRequested': + case 'slotResponseReceived': + case 'slotOnload': + case 'impressionViewable': + return Object.freeze({ ...base, kind: eventType }); + case 'slotVisibilityChanged': { + const percentage = safeMember(event as object, 'inViewPercentage'); + return typeof percentage === 'number' && Number.isFinite(percentage) + ? Object.freeze({ ...base, kind: eventType, inViewPercentage: percentage }) + : Object.freeze({ ...base, kind: eventType }); + } + case 'slotRenderEnded': { + const isEmpty = safeMember(event as object, 'isEmpty'); + const isBackfill = safeMember(event as object, 'isBackfill'); + const slotContentChanged = safeMember(event as object, 'slotContentChanged'); + const sizeCandidate = safeMember(event as object, 'size'); + let size: readonly [number, number] | undefined; + if (Array.isArray(sizeCandidate) && sizeCandidate.length === 2) { + const width = safeMember(sizeCandidate, '0'); + const height = safeMember(sizeCandidate, '1'); + if ( + typeof width === 'number' && + Number.isFinite(width) && + typeof height === 'number' && + Number.isFinite(height) + ) { + size = Object.freeze([width, height]); + } + } + return Object.freeze({ + ...base, + kind: eventType, + ...(typeof isEmpty === 'boolean' ? { isEmpty } : {}), + ...(size ? { size } : {}), + ...(typeof isBackfill === 'boolean' ? { isBackfill } : {}), + ...(typeof slotContentChanged === 'boolean' ? { slotContentChanged } : {}), + }); + } + default: + return undefined; + } + } catch { + return undefined; + } + }; + + const publishDiagnostics = (eventType: string, event: unknown): void => { + const observer = diagnosticsObserver; + if (!observer || disposed) return; + const fact = diagnosticFact(eventType, event); + if (!fact) return; + try { + observer(fact); + } catch { + // Diagnostics observation cannot escape the GPT correctness callback. + } + }; + const invokeFacadeCall = ( callable: (...arguments_: unknown[]) => unknown, receiver: unknown, @@ -1548,7 +1644,8 @@ export function createBrowserGoogletagAdapter( bindingToken, markFirstDisplay, invokeFacadeCall, - consumeFacadeCall + consumeFacadeCall, + publishDiagnostics ); try { if (disposed) { @@ -2156,8 +2253,32 @@ export function createBrowserGoogletagAdapter( return release; }; + const observeDiagnostics = (observer: GoogletagDiagnosticsObserver): (() => void) | undefined => { + if (disposed || typeof observer !== 'function' || diagnosticsObserver) return undefined; + diagnosticsObserver = observer; + let active = true; + const release = (): void => { + if (!active) return; + active = false; + if (diagnosticsObserver === observer) diagnosticsObserver = undefined; + try { + deleteSetValue(effects, release); + } catch { + // Exact observer release remains authoritative under registry failure. + } + }; + try { + registerAdapterEffect(release); + } catch (error) { + release(); + throw error; + } + return release; + }; + return Object.freeze({ bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + observeDiagnostics, observePublisherCalls, run, notifyReady, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts new file mode 100644 index 000000000..623ac9ba5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts @@ -0,0 +1,209 @@ +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, +} from '../../adapters/googletag'; + +const MAX_BUFFERED_FACTS = 512; +const DIAGNOSTICS_ONLY_EVENTS = Object.freeze([ + 'slotResponseReceived', + 'slotOnload', + 'impressionViewable', + 'slotVisibilityChanged', +] as const); + +export interface GptDiagnosticsFactBufferOptions { + readonly onConsumerError?: (error: unknown) => void; + readonly onOverflow?: (droppedFacts: number) => void; +} + +export interface GptDiagnosticsFactBuffer { + readonly publish: (fact: Readonly) => boolean; + readonly activate: (consumer: GoogletagDiagnosticsObserver) => (() => void) | undefined; + readonly dispose: () => void; +} + +function validFact(fact: unknown): fact is Readonly { + if (typeof fact !== 'object' || fact === null || !Object.isFrozen(fact)) return false; + const kind = Object.getOwnPropertyDescriptor(fact, 'kind'); + const slot = Object.getOwnPropertyDescriptor(fact, 'slot'); + return ( + ((kind !== undefined && + 'value' in kind && + DIAGNOSTICS_ONLY_EVENTS.includes(kind.value as (typeof DIAGNOSTICS_ONLY_EVENTS)[number])) || + kind?.value === 'slotRequested' || + kind?.value === 'slotRenderEnded') && + slot !== undefined && + 'value' in slot && + ((typeof slot.value === 'object' && slot.value !== null) || typeof slot.value === 'function') + ); +} + +/** Own the bounded handoff between early GPT callbacks and the diagnostics module. */ +export function createGptDiagnosticsFactBuffer( + options: GptDiagnosticsFactBufferOptions = {} +): GptDiagnosticsFactBuffer { + const pending: Readonly[] = []; + let consumer: GoogletagDiagnosticsObserver | undefined; + let consumerGeneration = 0; + let replaying = false; + let disposed = false; + let droppedFacts = 0; + + const reportConsumerError = (error: unknown): void => { + try { + options.onConsumerError?.(error); + } catch { + // Diagnostics error reporting cannot affect later fact delivery. + } + }; + const reportOverflow = (): void => { + droppedFacts += 1; + try { + options.onOverflow?.(droppedFacts); + } catch { + // Overflow reporting is diagnostics-only. + } + }; + const enqueue = (fact: Readonly): void => { + if (pending.length >= MAX_BUFFERED_FACTS) { + pending.shift(); + reportOverflow(); + } + pending.push(fact); + }; + const deliver = (fact: Readonly): void => { + const current = consumer; + if (!current) return; + try { + current(fact); + } catch (error) { + reportConsumerError(error); + } + }; + + return Object.freeze({ + publish: (fact: Readonly): boolean => { + if (disposed || !validFact(fact)) return false; + if (replaying || !consumer) enqueue(fact); + else deliver(fact); + return true; + }, + activate: (nextConsumer: GoogletagDiagnosticsObserver): (() => void) | undefined => { + if (disposed || consumer || typeof nextConsumer !== 'function') return undefined; + consumer = nextConsumer; + consumerGeneration += 1; + const generation = consumerGeneration; + replaying = true; + while (pending.length > 0 && consumer === nextConsumer && !disposed) { + const fact = pending.shift(); + if (fact) deliver(fact); + } + replaying = false; + if (!consumer || disposed) pending.length = 0; + let active = true; + return (): void => { + if (!active) return; + active = false; + if (consumerGeneration === generation && consumer === nextConsumer) consumer = undefined; + }; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + consumer = undefined; + replaying = false; + pending.length = 0; + }, + }); +} + +/** Connect the sole GPT adapter stream and only the four diagnostics-only listeners. */ +export function activateGptDiagnosticsFactCapture( + adapter: Pick, + buffer: Pick +): () => void { + let disposed = false; + let releases: readonly (() => void)[] = Object.freeze([]); + const observeDiagnostics = adapter.observeDiagnostics; + if (!observeDiagnostics) return () => undefined; + const releaseObserver = observeDiagnostics((fact) => { + try { + buffer.publish(fact); + } catch { + // Fact buffering cannot alter the already-completed GPT callback. + } + }); + if (!releaseObserver) return () => undefined; + + let operation: ReturnType | undefined; + try { + operation = adapter.run((gpt) => { + const installed: Array<() => void> = []; + try { + for (let index = 0; index < DIAGNOSTICS_ONLY_EVENTS.length; index += 1) { + const eventType = DIAGNOSTICS_ONLY_EVENTS[index]; + if (!eventType) continue; + installed[installed.length] = gpt.subscribe(eventType, () => undefined); + } + return Object.freeze(installed); + } catch (error) { + for (let index = installed.length - 1; index >= 0; index -= 1) { + try { + installed[index]?.(); + } catch { + // Continue rolling back the remaining listener ownership. + } + } + throw error; + } + }); + void operation.result.then( + (installed) => { + releases = installed as readonly (() => void)[]; + if (!disposed) return; + for (let index = releases.length - 1; index >= 0; index -= 1) { + try { + releases[index]?.(); + } catch { + // Late completion still releases every listener independently. + } + } + releases = Object.freeze([]); + }, + () => { + try { + releaseObserver(); + } catch { + // Failed activation retains no observer ownership. + } + } + ); + } catch { + releaseObserver(); + return () => undefined; + } + + return (): void => { + if (disposed) return; + disposed = true; + try { + operation?.dispose(); + } catch { + // Disposal continues through every independently owned resource. + } + for (let index = releases.length - 1; index >= 0; index -= 1) { + try { + releases[index]?.(); + } catch { + // One hostile listener release cannot retain the others. + } + } + releases = Object.freeze([]); + try { + releaseObserver(); + } catch { + // The adapter's disposed latch remains authoritative. + } + }; +} diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 90fefe4e0..f60b509ea 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1103,6 +1103,70 @@ describe('browser googletag adapter readiness', () => { expect(first.pubads.removeEventListener).toHaveBeenCalledTimes(1); }); + it('publishes frozen diagnostics facts after the sole adapter listener completes', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const order: string[] = []; + const facts: unknown[] = []; + const releaseDiagnostics = adapter.observeDiagnostics?.((fact) => { + order.push('diagnostics'); + facts.push(fact); + throw new Error('fictional diagnostics failure'); + }); + expect(releaseDiagnostics).toEqual(expect.any(Function)); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + + await adapter.run((gpt) => + gpt.subscribe('slotRenderEnded', () => { + order.push('correctness'); + }) + ).result; + expect(ready.pubads.addEventListener).toHaveBeenCalledTimes(1); + const slot = Object.freeze({ id: 'fictional-slot' }); + const emit = (event: unknown): void => { + for (const listener of ready.listeners.get('slotRenderEnded') ?? []) listener(event); + }; + expect(() => + emit({ + slot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }) + ).not.toThrow(); + + expect(order).toEqual(['correctness', 'diagnostics']); + expect(facts).toEqual([ + { + kind: 'slotRenderEnded', + slot, + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }, + ]); + expect(Object.isFrozen(facts[0])).toBe(true); + expect(Object.isFrozen((facts[0] as { size: unknown }).size)).toBe(true); + + releaseDiagnostics?.(); + emit({ slot, isEmpty: true }); + expect(facts).toHaveLength(1); + }); + + it('admits only one diagnostics observer without adding GPT listeners', () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const release = adapter.observeDiagnostics?.(vi.fn()); + + expect(adapter.observeDiagnostics?.(vi.fn())).toBeUndefined(); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + release?.(); + expect(adapter.observeDiagnostics?.(vi.fn())).toEqual(expect.any(Function)); + expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); + }); + it('rolls back an exact GPT listener when installation replaces the binding', async () => { const first = createReadyGoogletag(); const replacement = createReadyGoogletag(); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts new file mode 100644 index 000000000..085d89e0e --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + GoogletagAdapter, + GoogletagDiagnosticsFact, + GoogletagDiagnosticsObserver, + GoogletagFacade, +} from '../../../src/adapters/googletag'; +import { + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, +} from '../../../src/integrations/gpt_diagnostics/facts'; + +function fact(index: number): Readonly { + return Object.freeze({ kind: 'slotRequested', slot: Object.freeze({ index }) }); +} + +describe('GPT diagnostics fact transport', () => { + it('buffers 512 facts, evicts the oldest, replays in order, then releases the buffer', () => { + const buffer = createGptDiagnosticsFactBuffer(); + for (let index = 0; index < 513; index += 1) expect(buffer.publish(fact(index))).toBe(true); + const received: number[] = []; + + const release = buffer.activate((item) => { + received.push((item.slot as { index: number }).index); + }); + + expect(received).toHaveLength(512); + expect(received[0]).toBe(1); + expect(received[511]).toBe(512); + expect(buffer.publish(fact(513))).toBe(true); + expect(received[512]).toBe(513); + release?.(); + expect(buffer.publish(fact(514))).toBe(true); + expect(received).toHaveLength(513); + const replacement = vi.fn(); + expect(buffer.activate(replacement)).toEqual(expect.any(Function)); + expect(replacement).toHaveBeenCalledWith(fact(514)); + buffer.dispose(); + expect(buffer.publish(fact(515))).toBe(false); + }); + + it('isolates consumer throws and admits only one live module consumer', () => { + const errors: unknown[] = []; + const buffer = createGptDiagnosticsFactBuffer({ + onConsumerError: (error) => errors.push(error), + }); + buffer.publish(fact(1)); + const release = buffer.activate(() => { + throw new Error('fictional consumer failure'); + }); + + expect(errors).toHaveLength(1); + expect(buffer.activate(vi.fn())).toBeUndefined(); + expect(buffer.publish(fact(2))).toBe(true); + expect(errors).toHaveLength(2); + release?.(); + expect(buffer.activate(vi.fn())).toEqual(expect.any(Function)); + buffer.dispose(); + }); + + it('adds only the four non-correctness GPT listeners while active and disposes all ownership', async () => { + const subscriptions: string[] = []; + const releases: Array> = []; + let observer: GoogletagDiagnosticsObserver | undefined; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + subscribe: (eventType: string, _listener: (event: unknown) => void) => { + subscriptions.push(eventType); + const release = vi.fn(); + releases.push(release); + return release; + }, + }) as unknown as Readonly; + const adapter = Object.freeze({ + observeDiagnostics: (candidate: GoogletagDiagnosticsObserver) => { + observer = candidate; + return () => { + observer = undefined; + }; + }, + run: (command: (gpt: Readonly) => Value) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }), + }) as unknown as GoogletagAdapter; + const buffer = createGptDiagnosticsFactBuffer(); + + const dispose = activateGptDiagnosticsFactCapture(adapter, buffer); + await Promise.resolve(); + + expect(observer).toEqual(expect.any(Function)); + expect(subscriptions.sort()).toEqual( + ['impressionViewable', 'slotOnload', 'slotResponseReceived', 'slotVisibilityChanged'].sort() + ); + dispose(); + dispose(); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); + expect(observer).toBeUndefined(); + }); +}); From f602d6bf6f4948712671f828cea0deac4c2bd0cb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:56:11 -0700 Subject: [PATCH 367/494] Commit publisher GPT intent transactionally --- .../lib/src/services/slots.ts | 256 +++++++++++++++--- .../lib/test/services/slots.test.ts | 238 +++++++++++++++- 2 files changed, 442 insertions(+), 52 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 1521f50d6..fcbfe584b 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -2,6 +2,7 @@ import type { GoogletagAdapter, GoogletagFacade, GoogletagOperation, + GoogletagPublisherCallAdmission, GoogletagPublisherDefineSlotCall, GoogletagPublisherDisplayCall, GoogletagPublisherRefreshCall, @@ -155,12 +156,16 @@ export interface SlotService { ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'handoff'; slot: object }>; readonly preparePublisherDisplay: ( call: GoogletagPublisherDisplayCall - ) => Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }>; - readonly preparePublisherRefresh: ( - call: GoogletagPublisherRefreshCall ) => - | Readonly<{ action: 'forward' }> - | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ action: 'suppress' }>; + readonly preparePublisherRefresh: (call: GoogletagPublisherRefreshCall) => + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ + action: 'replace'; + slots: readonly object[]; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }>; readonly projectionRegistry: (owner: NavigationSession) => ProjectionSlotRegistry; readonly recordPublisherDestruction: (slot: object) => boolean; @@ -239,7 +244,7 @@ interface PhysicalSlot { lastResponseIdentifier: string | undefined; ownership: GptSlotOwnership; placementKeys: readonly string[]; - publisherIntentCount: number; + readonly publisherAdmissions: PublisherIntentAdmissionState[]; publisherElementIds: readonly string[]; suppressPublisherDisplay: boolean; suppressPublisherRefresh: boolean; @@ -251,6 +256,14 @@ interface PhysicalSlot { destroyAttempted: boolean; } +interface PublisherIntentAdmissionState { + enqueued: boolean; + phase: 'committed' | 'consumed' | 'pending' | 'rolled_back'; + readonly generation: object | undefined; + readonly physical: PhysicalSlot; + readonly record: InternalSlotRecord | undefined; +} + interface ReconciliationWindow { debounceTimer: ReturnType | undefined; deadlineTimer: ReturnType | undefined; @@ -956,7 +969,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: oldPhysical.placementKeys, - publisherIntentCount: 0, + publisherAdmissions: [], publisherElementIds: Object.freeze([]), quarantineReason: undefined, record, @@ -1021,7 +1034,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { lastResponseIdentifier: undefined, ownership: 'trusted_server', placementKeys: source.placementKeys, - publisherIntentCount: 0, + publisherAdmissions: [], publisherElementIds: Object.freeze([]), quarantineReason: 'request', record: undefined, @@ -1372,15 +1385,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!physical) return; if (type === 'slotRequested') { + const publisherAdmission = physical.publisherAdmissions[0]; + if (publisherAdmission) { + const phase = publisherAdmission.phase; + removePublisherAdmission(publisherAdmission); + publisherAdmission.phase = 'consumed'; + if (phase === 'pending') applyPublisherIntent(physical); + if (!physical.activeCycle && physical.state === 'live') { + physical.activeCycle = { intent: undefined, kind: 'publisher' }; + } + return; + } if (physical.state !== 'live' || physical.activeCycle) return; const record = physical.record; const intent = record?.activeIntent; - if (physical.publisherIntentCount > 0) { - physical.publisherIntentCount -= 1; - if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); - physical.activeCycle = { intent: undefined, kind: 'publisher' }; - return; - } if ( intent && !intent.terminal && @@ -1457,6 +1475,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const retirePhysicalForNavigation = (physical: PhysicalSlot): void => { const record = physical.record; if (record) retireCommittedArtifact(record, physical); + invalidatePublisherAdmissions(physical); physical.record = undefined; if (physical.ownership === 'publisher') { if (physical.activeCycle) { @@ -1887,7 +1906,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if ( physical.ownership !== 'trusted_server' || physical.state !== 'live' || - physical.publisherIntentCount > 0 || + physical.publisherAdmissions.length > 0 || !physical.definition ) { cancelReconciliation(record); @@ -2250,7 +2269,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { lastResponseIdentifier: undefined, ownership, placementKeys: bindingPlacementKeys, - publisherIntentCount: 0, + publisherAdmissions: [], publisherElementIds: ownership === 'publisher' && definition ? Object.freeze([definition.elementId]) @@ -2332,7 +2351,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { settle(intent, failed('gpt_request_failed')); return handle; } - if (physical.publisherIntentCount > 0) { + if (physical.publisherAdmissions.length > 0) { settle(intent, failed('cycle_unattributable')); return handle; } @@ -2462,7 +2481,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.record !== record || physical.state !== 'live' || physical.activeCycle || - physical.publisherIntentCount > 0 || + physical.publisherAdmissions.length > 0 || setHasValue(admittedRecords, record) || setHasValue(admittedPhysicalSlots, physical.slot) ) { @@ -2660,7 +2679,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (record?.physical === physical) record.physical = undefined; physical.record = undefined; physical.activeCycle = undefined; - physical.publisherIntentCount = 0; + invalidatePublisherAdmissions(physical); physical.publisherElementIds = Object.freeze([]); physical.suppressPublisherDisplay = false; physical.suppressPublisherRefresh = false; @@ -2673,36 +2692,173 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return true; }; - const recordPublisherIntent = (slot: object): boolean => { - const physical = weakMapValue(physicalByObject, slot); - if (!physical || (physical.state !== 'live' && !physical.activeCycle)) return false; - if (physical.publisherIntentCount >= MAX_PENDING_PUBLISHER_INTENTS) { - physical.state = 'quarantined'; - physical.quarantineReason = 'request'; - quarantinePhysicalPlacement(physical); - if (physical.record?.activeIntent) { - settle(physical.record.activeIntent, failed('cycle_unattributable')); - } - if (physical.record?.queuedIntent) { - settle(physical.record.queuedIntent, failed('cycle_unattributable')); + const publisherAdmissionIndex = (state: PublisherIntentAdmissionState): number => { + const admissions = state.physical.publisherAdmissions; + for (let index = 0; index < admissions.length; index += 1) { + if (admissions[index] === state) return index; + } + return -1; + }; + + const invalidatePublisherAdmissions = (physical: PhysicalSlot): void => { + for (let index = 0; index < physical.publisherAdmissions.length; index += 1) { + const admission = physical.publisherAdmissions[index]; + if (!admission) continue; + admission.enqueued = false; + if (admission.phase === 'pending' || admission.phase === 'committed') { + admission.phase = 'rolled_back'; } + } + physical.publisherAdmissions.length = 0; + }; + + const removePublisherAdmission = (state: PublisherIntentAdmissionState): boolean => { + if (!state.enqueued) return false; + const physical = state.physical; + const admissions = physical.publisherAdmissions; + const admissionIndex = publisherAdmissionIndex(state); + state.enqueued = false; + if (admissionIndex < 0) return false; + for (let index = admissionIndex; index < admissions.length - 1; index += 1) { + const next = admissions[index + 1]; + if (next) admissions[index] = next; + } + admissions.length -= 1; + return true; + }; + + const publisherAdmissionIsCurrent = (state: PublisherIntentAdmissionState): boolean => { + const physical = state.physical; + if ( + !state.enqueued || + publisherAdmissionIndex(state) < 0 || + weakMapValue(physicalByObject, physical.slot) !== physical || + physical.record !== state.record || + (physical.state !== 'live' && !physical.activeCycle) + ) { return false; } + const record = state.record; + return ( + !record || + (!record.state.disposed && + record.state.owner.isCurrent() && + record.state.owner.generation === state.generation) + ); + }; + + const failPublisherIntentOverflow = (physical: PhysicalSlot): void => { + if (physical.state === 'retired') return; + physical.state = 'quarantined'; + physical.quarantineReason = 'request'; + quarantinePhysicalPlacement(physical); + if (physical.record?.activeIntent) { + settle(physical.record.activeIntent, failed('cycle_unattributable')); + } + if (physical.record?.queuedIntent) { + settle(physical.record.queuedIntent, failed('cycle_unattributable')); + } + }; + + const applyPublisherIntent = (physical: PhysicalSlot): void => { if (physical.record?.activeIntent) { settle(physical.record.activeIntent, failed('cycle_unattributable')); } if (physical.record?.queuedIntent) { settle(physical.record.queuedIntent, failed('cycle_unattributable')); } - physical.publisherIntentCount += 1; if (physical.activeCycle?.kind === 'trusted_server') { physical.activeCycle = { intent: undefined, kind: 'publisher' }; physical.state = 'quarantined'; physical.quarantineReason = 'completion'; } + }; + + const rollbackPublisherAdmission = (state: PublisherIntentAdmissionState): void => { + if (state.phase !== 'pending') return; + removePublisherAdmission(state); + state.phase = 'rolled_back'; + }; + + const commitPublisherAdmission = (state: PublisherIntentAdmissionState): boolean => { + if (state.phase === 'committed' || state.phase === 'consumed') return true; + if (state.phase !== 'pending') return false; + if (!publisherAdmissionIsCurrent(state)) { + rollbackPublisherAdmission(state); + return false; + } + const physical = state.physical; + if (physical.publisherAdmissions.length > MAX_PENDING_PUBLISHER_INTENTS) { + removePublisherAdmission(state); + state.phase = 'rolled_back'; + failPublisherIntentOverflow(physical); + return false; + } + state.phase = 'committed'; + applyPublisherIntent(physical); return true; }; + const preparePublisherIntent = ( + slot: object + ): + | Readonly<{ + readonly admission: GoogletagPublisherCallAdmission; + readonly commit: () => boolean; + }> + | undefined => { + const physical = weakMapValue(physicalByObject, slot); + if ( + !physical || + (physical.state !== 'live' && !physical.activeCycle) || + physical.publisherAdmissions.length > MAX_PENDING_PUBLISHER_INTENTS + ) { + return undefined; + } + const record = physical.record; + const state: PublisherIntentAdmissionState = { + enqueued: true, + generation: record?.state.owner.generation, + phase: 'pending', + physical, + record, + }; + physical.publisherAdmissions[physical.publisherAdmissions.length] = state; + const admission: GoogletagPublisherCallAdmission = Object.freeze({ + commit: (): void => { + commitPublisherAdmission(state); + }, + rollback: (): void => { + rollbackPublisherAdmission(state); + }, + }); + return Object.freeze({ admission, commit: () => commitPublisherAdmission(state) }); + }; + + const compositePublisherAdmission = ( + admissions: readonly GoogletagPublisherCallAdmission[] + ): GoogletagPublisherCallAdmission | undefined => { + if (admissions.length === 0) return undefined; + return Object.freeze({ + commit: (): void => { + for (let index = 0; index < admissions.length; index += 1) { + admissions[index]?.commit(); + } + }, + rollback: (): void => { + for (let index = admissions.length - 1; index >= 0; index -= 1) { + admissions[index]?.rollback(); + } + }, + }); + }; + + const recordPublisherIntent = (slot: object): boolean => { + const prepared = preparePublisherIntent(slot); + if (!prepared) return false; + return prepared.commit(); + }; + const publisherPhysicalForTarget = (target: unknown): PhysicalSlot | undefined => { if ((typeof target === 'object' && target !== null) || typeof target === 'function') { const exact = weakMapValue(physicalByObject, target as object); @@ -2818,7 +2974,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const preparePublisherDisplay = ( call: GoogletagPublisherDisplayCall - ): Readonly<{ action: 'forward' }> | Readonly<{ action: 'suppress' }> => { + ): + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ action: 'suppress' }> => { let target: unknown; let initialLoadDisabled: unknown; try { @@ -2833,15 +2991,22 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.suppressPublisherDisplay = false; return Object.freeze({ action: 'suppress' }); } - if (initialLoadDisabled !== true) recordPublisherIntent(physical.slot); - return Object.freeze({ action: 'forward' }); + const prepared = + initialLoadDisabled === true ? undefined : preparePublisherIntent(physical.slot); + return prepared + ? Object.freeze({ action: 'forward', admission: prepared.admission }) + : Object.freeze({ action: 'forward' }); }; const preparePublisherRefresh = ( call: GoogletagPublisherRefreshCall ): - | Readonly<{ action: 'forward' }> - | Readonly<{ action: 'replace'; slots: readonly object[] }> + | Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }> + | Readonly<{ + action: 'replace'; + slots: readonly object[]; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }> => { let slots: readonly object[]; try { @@ -2852,6 +3017,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!Array.isArray(slots)) return Object.freeze({ action: 'forward' }); let suppressed = false; const forwarded: object[] = []; + const admissions: GoogletagPublisherCallAdmission[] = []; for (let index = 0; index < slots.length; index += 1) { const slot = slots[index]; if (!slot) continue; @@ -2862,11 +3028,21 @@ export function createSlotService(options: SlotServiceOptions): SlotService { continue; } forwarded[forwarded.length] = slot; - if (physical?.ownership === 'publisher') recordPublisherIntent(slot); + if (physical?.ownership === 'publisher') { + const prepared = preparePublisherIntent(slot); + if (prepared) admissions[admissions.length] = prepared.admission; + } + } + const admission = compositePublisherAdmission(admissions); + if (!suppressed) { + return admission + ? Object.freeze({ action: 'forward', admission }) + : Object.freeze({ action: 'forward' }); } - if (!suppressed) return Object.freeze({ action: 'forward' }); if (forwarded.length === 0) return Object.freeze({ action: 'suppress' }); - return Object.freeze({ action: 'replace', slots: Object.freeze(forwarded) }); + return admission + ? Object.freeze({ action: 'replace', admission, slots: Object.freeze(forwarded) }) + : Object.freeze({ action: 'replace', slots: Object.freeze(forwarded) }); }; const service: SlotService = Object.freeze({ diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index b903026fd..0c8be81c8 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -5,6 +5,7 @@ import { GoogletagReplacementError, type GoogletagAdapter, type GoogletagFacade, + type GoogletagPublisherCallAdmission, type GoogletagReplacementCommitAdmission, type GoogletagReplacementDefinition, } from '../../src/adapters/googletag'; @@ -550,12 +551,15 @@ describe('slot registry', () => { slots: Object.freeze([slot, unrelated]), }) ).toEqual({ action: 'replace', slots: [unrelated] }); - expect( - service.preparePublisherRefresh({ - requestedSlots: Object.freeze([slot]), - slots: Object.freeze([slot]), - }) - ).toEqual({ action: 'forward' }); + const forwardedRefresh = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }); + expect(forwardedRefresh.action).toBe('forward'); + if (forwardedRefresh.action === 'forward') { + expect(forwardedRefresh.admission).toBeDefined(); + forwardedRefresh.admission?.commit(); + } const request = service.request({ intentId: 'after-publisher-refresh', @@ -593,6 +597,213 @@ describe('slot registry', () => { expect(warnPublisherHandoffMismatch).not.toHaveBeenCalled(); }); + it('rolls back a pending publisher display without settling active or queued TS work', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + expect( + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }) + ).toEqual({ action: 'handoff', slot }); + expect( + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }) + ).toEqual({ action: 'suppress' }); + const active = service.request({ + intentId: 'active-before-publisher-display', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + const queued = service.request({ + intentId: 'queued-before-publisher-display', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(decision.action).toBe('forward'); + expect(decision.admission).toBeDefined(); + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + + decision.admission?.rollback(); + decision.admission?.rollback(); + + expect(active.status).toBe('active'); + expect(queued.status).toBe('queued'); + service.dispose(); + await expect(active.result).resolves.toMatchObject({ status: 'cancelled' }); + await expect(queued.result).resolves.toMatchObject({ status: 'cancelled' }); + }); + + it('keeps a publisher cycle consumed before display rollback and makes later rollback inert', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }); + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(decision.admission).toBeDefined(); + + service.handleGptEvent('slotRequested', { slot }); + expect(service.snapshotForTest().cycles).toBe(1); + decision.admission?.rollback(); + decision.admission?.commit(); + expect(service.snapshotForTest().cycles).toBe(1); + + service.handleGptEvent('slotRenderEnded', { isEmpty: false, slot }); + expect(service.snapshotForTest().cycles).toBe(0); + }); + + it('rolls back repeated display plus explicit and global refresh admissions without residue', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + const second = bindTrustedSlot(service, navigation, 'second'); + for (const registeredSlotId of ['first', 'second'] as const) { + service.claimPublisherGptSlot({ + adUnitPath: `/network/${registeredSlotId}`, + elementId: `${registeredSlotId}-div`, + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: `${registeredSlotId}-div`, + }); + } + + for (let attempt = 0; attempt < 70; attempt += 1) { + const display = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'first-div', + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(display.admission).toBeDefined(); + display.admission?.rollback(); + } + const explicit = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([first]), + slots: Object.freeze([first]), + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + const global = service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([first, second]), + }) as Readonly<{ action: 'forward'; admission?: GoogletagPublisherCallAdmission }>; + expect(explicit.admission).toBeDefined(); + expect(global.admission).toBeDefined(); + explicit.admission?.rollback(); + global.admission?.rollback(); + + const firstRequest = service.request({ + intentId: 'after-rolled-back-explicit-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + const secondRequest = service.request({ + intentId: 'after-rolled-back-global-refresh', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }); + expect(firstRequest.status).toBe('active'); + expect(secondRequest.status).toBe('active'); + }); + + it('commits a global refresh only for the publisher physicals snapshotted before native entry', async () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const navigation = createNavigation(); + const first = bindTrustedSlot(service, navigation, 'first'); + bindTrustedSlot(service, navigation, 'second'); + service.claimPublisherGptSlot({ + adUnitPath: '/network/first', + elementId: 'first-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + const global = service.preparePublisherRefresh({ + requestedSlots: undefined, + slots: Object.freeze([first]), + }); + expect(global.action).toBe('forward'); + if (global.action !== 'forward') throw new Error('Expected global refresh forwarding'); + expect(global.admission).toBeDefined(); + + service.claimPublisherGptSlot({ + adUnitPath: '/network/second', + elementId: 'second-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + global.admission?.commit(); + + const firstRequest = service.request({ + intentId: 'global-snapshot-first', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'first', + requestClass: 'primary', + }); + const secondRequest = service.request({ + intentId: 'global-snapshot-second', + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'second', + requestClass: 'primary', + }); + await expect(firstRequest.result).resolves.toMatchObject({ reason: 'cycle_unattributable' }); + expect(secondRequest.status).toBe('active'); + }); + + it('makes a pending publisher admission inert after navigation and service disposal', () => { + const service = createSlotService({ googletag: createGptHarness().adapter }); + const { navigation, runtime } = createRuntimeWithNavigation(); + bindTrustedSlot(service, navigation); + service.claimPublisherGptSlot({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + initialLoadDisabled: false, + sizes: [300, 250], + }); + service.preparePublisherDisplay({ initialLoadDisabled: false, target: 'slot-div' }); + const decision = service.preparePublisherDisplay({ + initialLoadDisabled: false, + target: 'slot-div', + }); + expect(decision.action).toBe('forward'); + if (decision.action !== 'forward') throw new Error('Expected display forwarding'); + expect(decision.admission).toBeDefined(); + + runtime.dispose(); + expect(() => decision.admission?.commit()).not.toThrow(); + expect(() => decision.admission?.rollback()).not.toThrow(); + service.dispose(); + expect(() => decision.admission?.commit()).not.toThrow(); + expect(() => decision.admission?.rollback()).not.toThrow(); + }); + it('hydrates only one disconnected TS fallback with the configured prefix, path, and sizes', () => { const dom = createReconciliationBoundary(); const firstElement = {}; @@ -667,12 +878,15 @@ describe('slot registry', () => { slots: Object.freeze([slot]), }) ).toEqual({ action: 'suppress' }); - expect( - service.preparePublisherRefresh({ - requestedSlots: Object.freeze([slot]), - slots: Object.freeze([slot]), - }) - ).toEqual({ action: 'forward' }); + const forwarded = service.preparePublisherRefresh({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + }); + expect(forwarded.action).toBe('forward'); + if (forwarded.action === 'forward') { + expect(forwarded.admission).toBeDefined(); + forwarded.admission?.commit(); + } }); it('uses captured Set validation intrinsics on a hostile page', () => { From b555d4210e28db6372a94941fc121296c7490f5e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:00:57 -0700 Subject: [PATCH 368/494] Name and complete ts_console request gates --- .../src/integrations/gpt_diagnostics.rs | 58 ++++++++++++++++--- crates/trusted-server-core/src/publisher.rs | 2 +- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 068c43cc0..350a09cf8 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -371,7 +371,7 @@ mod tests { } #[test] - fn register_excludes_diagnostics_from_unified_and_deferred_bundles() { + fn ts_console_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)); @@ -388,7 +388,7 @@ mod tests { } #[test] - fn exact_directive_activates_cleans_and_strips_cookie() { + fn ts_console_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"), @@ -412,7 +412,7 @@ mod tests { } #[test] - fn prefetch_directive_is_sanitized_without_activating_session() { + fn ts_console_prefetch_directive_is_sanitized_without_activating_session() { let mut request = navigation("https://publisher.example/page?ts_console=1&keep=1", None); request .headers_mut() @@ -429,7 +429,7 @@ mod tests { } #[test] - fn active_cookie_enables_clean_navigation_but_duplicates_fail_closed() { + fn ts_console_active_cookie_enables_clean_navigation_but_duplicates_fail_closed() { let mut active = navigation( "https://publisher.example/page", Some("__Host-ts-console=1; other=value"), @@ -448,7 +448,7 @@ mod tests { } #[test] - fn invalid_duplicate_and_disable_directives_fail_closed() { + fn ts_console_invalid_duplicate_and_disable_directives_fail_closed() { for query in [ "ts_console=True", "ts_console=", @@ -477,7 +477,7 @@ mod tests { } #[test] - fn finalization_sets_cookie_and_strips_shared_cache_headers() { + fn ts_console_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() @@ -505,7 +505,51 @@ mod tests { } #[test] - fn config_rejects_unknown_fields() { + fn ts_console_only_eligible_get_document_navigations_activate() { + for (method, destination) in [(Method::POST, "document"), (Method::GET, "script")] { + let mut request = Request::builder() + .method(method.clone()) + .uri("https://publisher.example/page?keep=1&ts_console=1") + .header("sec-fetch-dest", destination) + .header(header::COOKIE, "__Host-ts-console=1; other=value") + .body(EdgeBody::empty()) + .expect("should build ineligible request"); + + let decision = prepare_request(&settings(true), &mut request) + .expect("should sanitize ineligible request"); + + assert!( + !decision.active(), + "{method} {destination} must not activate" + ); + assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); + assert_eq!(request.uri().query(), Some("keep=1")); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + } + } + + #[test] + fn ts_console_disable_emits_the_exact_session_cookie_clear() { + let mut request = navigation( + "https://publisher.example/page?ts_console=0&keep=1", + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let mut response = Response::new(EdgeBody::empty()); + + finalize_response(&decision, &mut response); + + assert!(!decision.active()); + assert_eq!(request.uri().query(), Some("keep=1")); + assert_eq!(response.headers()[header::SET_COOKIE], CLEAR_CONSOLE_COOKIE); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + } + + #[test] + fn ts_console_config_rejects_unknown_fields() { let mut settings = create_test_settings(); settings .integrations diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 778addf82..cd1852162 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5149,7 +5149,7 @@ mod tests { } #[test] - fn stream_publisher_body_injects_active_diagnostics_for_materialized_html() { + fn ts_console_stream_publisher_body_injects_active_diagnostics_for_materialized_html() { let mut settings = create_test_settings(); settings .integrations From 03d85614bfb7b8d922a5e9c360ca84d2e95c33ff Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:07:57 -0700 Subject: [PATCH 369/494] Rebuild bounded GPT diagnostics --- .../lib/eslint-rules/no-adtech-globals.js | 1 - .../lib/src/adapters/googletag.ts | 2 +- .../lib/src/composition/browser.ts | 51 +++- .../src/integrations/gpt_diagnostics/facts.ts | 7 +- .../src/integrations/gpt_diagnostics/index.ts | 162 +++++------ .../integrations/gpt_diagnostics/module.ts | 91 ++++++ .../integrations/gpt_diagnostics/observer.ts | 177 ++++-------- .../lib/test/composition/browser.test.ts | 201 ++++++++++++- .../test/eslint/no-adtech-globals.test.mjs | 1 - .../gpt_diagnostics/facts.test.ts | 25 +- .../gpt_diagnostics/index.test.ts | 241 +++++----------- .../gpt_diagnostics/module.test.ts | 111 ++++++++ .../gpt_diagnostics/observer.test.ts | 263 +++--------------- .../lib/test/services/slots.test.ts | 2 + 14 files changed, 722 insertions(+), 613 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts diff --git a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js index 78aff60b6..d2c4d2b4a 100644 --- a/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js +++ b/crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js @@ -7,7 +7,6 @@ const GLOBAL_ROOTS = new Set(['globalThis', 'self', 'window']); export const LEGACY_ADTECH_GLOBAL_ALLOWLIST = Object.freeze([ 'src/integrations/gpt/index.ts', - 'src/integrations/gpt_diagnostics/observer.ts', 'src/integrations/prebid/index.ts', ]); diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 4585a25eb..5d65079fd 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -183,7 +183,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; - observeDiagnostics?(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; + observeDiagnostics(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; observePublisherCalls(observer: GoogletagPublisherCallObserver): () => void; run( command: (googletag: Readonly) => T, diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 60e369886..c8dd23d22 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -54,6 +54,15 @@ import { type GptWinnerPublicationResult, } from '../integrations/gpt/module'; import { createGptStartup } from '../integrations/gpt/startup'; +import { + activateGptDiagnosticsFactCapture, + createGptDiagnosticsFactBuffer, + type GptDiagnosticsFactBuffer, +} from '../integrations/gpt_diagnostics/facts'; +import { + createGptDiagnosticsRuntime, + type GptDiagnosticsRuntime, +} from '../integrations/gpt_diagnostics'; import { createPrebidSelectionCoordinator, publishPrebidBid, @@ -287,7 +296,10 @@ export function createTestBrowserRuntimeComposition( const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; let creativeBoot: Readonly | undefined; + let diagnosticsBoot: Readonly | undefined; let diagnosticsBus: DiagnosticsBus | undefined; + let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; + let gptDiagnosticsRuntime: GptDiagnosticsRuntime | undefined; let renderTrace: RenderTraceRuntimeOwner | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { if ( @@ -326,7 +338,11 @@ export function createTestBrowserRuntimeComposition( const diagnosticsForPublish = (): Readonly => { const trace = renderTrace; if (!trace) throw new Error('Render diagnostics are unavailable'); - return Object.freeze({ renderTrace: trace.diagnostics }); + const gpt = gptDiagnosticsRuntime?.currentApi(); + if (diagnosticsBoot?.gpt.active && !gpt) { + throw new Error('GPT diagnostics are unavailable'); + } + return Object.freeze({ renderTrace: trace.diagnostics, ...(gpt ? { gpt } : {}) }); }; const defaultCreativeRuntime = typeof document === 'undefined' @@ -434,6 +450,7 @@ export function createTestBrowserRuntimeComposition( config = descriptor.value; } if (id === 'creative' && config === undefined) config = creativeBoot; + if (id === 'gpt_diagnostics' && config === undefined) config = diagnosticsBoot?.gpt; const interfaces = runtimeSession?.interfaces; if (!interfaces) throw new Error(`Integration interfaces are unavailable for ${id}`); return Object.freeze({ @@ -633,6 +650,7 @@ export function createTestBrowserRuntimeComposition( prepareOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; creativeBoot = boot.creative; + diagnosticsBoot = boot.diagnostics; const cachePolicy = boot.cachePolicy === undefined ? undefined : parseCacheFetchPolicyV1(boot.cachePolicy); const parseProjection = (candidate: unknown): object | undefined => @@ -652,9 +670,26 @@ export function createTestBrowserRuntimeComposition( }); renderTrace = preparedRenderTrace; diagnosticsBus = preparedDiagnosticsBus; + const preparedGptDiagnosticsFacts = boot.diagnostics.gpt.active + ? createGptDiagnosticsFactBuffer({ + onConsumerError: (error) => log.warn('gpt diagnostics: fact consumer failed', error), + }) + : undefined; + const preparedGptDiagnosticsRuntime = preparedGptDiagnosticsFacts + ? createGptDiagnosticsRuntime(preparedGptDiagnosticsFacts) + : undefined; + gptDiagnosticsFacts = preparedGptDiagnosticsFacts; + gptDiagnosticsRuntime = preparedGptDiagnosticsRuntime; context.onDispose(() => { + preparedGptDiagnosticsFacts?.dispose(); preparedDiagnosticsBus.dispose(); preparedRenderTrace.dispose(); + if (gptDiagnosticsFacts === preparedGptDiagnosticsFacts) { + gptDiagnosticsFacts = undefined; + } + if (gptDiagnosticsRuntime === preparedGptDiagnosticsRuntime) { + gptDiagnosticsRuntime = undefined; + } if (diagnosticsBus === preparedDiagnosticsBus) diagnosticsBus = undefined; if (renderTrace === preparedRenderTrace) renderTrace = undefined; }); @@ -855,6 +890,9 @@ export function createTestBrowserRuntimeComposition( adapters: composition.adapters, creative: creativeRuntime, diagnostics: Object.freeze({ subscribe: preparedDiagnosticsBus.subscribe }), + ...(preparedGptDiagnosticsRuntime + ? { gpt_diagnostics: preparedGptDiagnosticsRuntime } + : {}), gpt: gptRuntime, prebid: prebidRuntime, ...services, @@ -880,6 +918,7 @@ export function createTestBrowserRuntimeComposition( auctionContextRegistry = undefined; projectionParser = undefined; creativeBoot = undefined; + diagnosticsBoot = undefined; } }); const navigation = session.startInitialNavigation(initialProjection); @@ -912,6 +951,15 @@ export function createTestBrowserRuntimeComposition( activateCore: (context) => { const prepared = preparedBrowserServices; if (!prepared) throw new Error('Browser services are unavailable'); + const facts = gptDiagnosticsFacts; + if (facts) { + const releaseCapture = activateGptDiagnosticsFactCapture( + composition.adapters.googletag, + facts + ); + if (!releaseCapture) throw new Error('GPT diagnostics capture is unavailable'); + context.onDispose(releaseCapture); + } const pucBridge = createPucBridge({ messaging: composition.adapters.messaging, publisherOrigin: prepared.publisherOrigin, @@ -952,6 +1000,7 @@ export function createTestBrowserRuntimeComposition( if (prebidCoordinator === coordinator) prebidCoordinator = undefined; }); browserServices.slots.activate(); + browserServices.slots.start(); compositionOptions.coreActivations.correctnessGptListeners( context, composition.adapters, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts index 623ac9ba5..9c3ef2375 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/facts.ts @@ -122,11 +122,10 @@ export function createGptDiagnosticsFactBuffer( export function activateGptDiagnosticsFactCapture( adapter: Pick, buffer: Pick -): () => void { +): (() => void) | undefined { let disposed = false; let releases: readonly (() => void)[] = Object.freeze([]); const observeDiagnostics = adapter.observeDiagnostics; - if (!observeDiagnostics) return () => undefined; const releaseObserver = observeDiagnostics((fact) => { try { buffer.publish(fact); @@ -134,7 +133,7 @@ export function activateGptDiagnosticsFactCapture( // Fact buffering cannot alter the already-completed GPT callback. } }); - if (!releaseObserver) return () => undefined; + if (!releaseObserver) return undefined; let operation: ReturnType | undefined; try { @@ -181,7 +180,7 @@ export function activateGptDiagnosticsFactCapture( ); } catch { releaseObserver(); - return () => undefined; + return undefined; } return (): void => { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index 1265585b0..e552f9112 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,101 +1,107 @@ -import { log } from '../../core/log'; -import type { GptDiagnosticsApi, LegacyTsjsApi } from '../../core/types'; +import type { GptDiagnosticsApi } from '../../core/types'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; import { GptDiagnosticsBindingManager } from './binding'; +import type { GptDiagnosticsFactBuffer } from './facts'; import { GptDiagnosticsObserver } from './observer'; -import type { GptObserverWindow } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; import { GptDiagnosticsStore } from './store'; -interface GptDiagnosticsRuntime { - api: GptDiagnosticsApi; - destroy(): void; +type GptDiagnosticsWindow = Window & typeof globalThis; + +export interface GptDiagnosticsRuntimeOptions { + readonly document?: Document | undefined; + readonly window?: GptDiagnosticsWindow | undefined; } -type GptDiagnosticsWindow = Window & - typeof globalThis & - GptObserverWindow & { - __tsjs_gpt_diagnostics_active?: boolean; - __tsjs_gpt_diagnostics_runtime?: GptDiagnosticsRuntime; - tsjs?: LegacyTsjsApi; - }; +export interface GptDiagnosticsRuntime { + readonly activate: () => () => void; + readonly currentApi: () => GptDiagnosticsApi | undefined; +} -/** Whether the early bootstrap activated diagnostics for this document. */ -export function isGptDiagnosticsActive( - target: Pick< - GptDiagnosticsWindow, - '__tsjs_gpt_diagnostics_active' - > = window as GptDiagnosticsWindow -): boolean { - return target.__tsjs_gpt_diagnostics_active === true; +interface ActiveRuntime { + readonly api: GptDiagnosticsApi; + readonly release: () => void; } -/** Installs one active diagnostics runtime for the current document. */ -export function installGptDiagnosticsRuntime( - target: GptDiagnosticsWindow = window as GptDiagnosticsWindow -): GptDiagnosticsApi | undefined { - if (!isGptDiagnosticsActive(target)) return undefined; - if (target.__tsjs_gpt_diagnostics_runtime) { - return target.__tsjs_gpt_diagnostics_runtime.api; +function isolate(callback: () => void): void { + try { + callback(); + } catch { + // Diagnostics cleanup cannot retain another independently owned resource. } +} - let bindings: GptDiagnosticsBindingManager | undefined; - let badges: GptDiagnosticsBadgeManager | undefined; - let overlay: GptDiagnosticsOverlay | undefined; - let apiController: GptDiagnosticsApiController | undefined; +/** Creates an inert GPT diagnostics runtime over the adapter-owned fact transport. */ +export function createGptDiagnosticsRuntime( + facts: Pick, + options: GptDiagnosticsRuntimeOptions = {} +): GptDiagnosticsRuntime { + const targetWindow = options.window ?? (window as GptDiagnosticsWindow); + const targetDocument = options.document ?? document; + let active: ActiveRuntime | undefined; - try { - if (!target.tsjs) throw new Error('TSJS core API unavailable'); + const activate = (): (() => void) => { + if (active) throw new Error('GPT diagnostics runtime is already active'); const store = new GptDiagnosticsStore(); - const observer = new GptDiagnosticsObserver(store, { window: target }); - bindings = new GptDiagnosticsBindingManager(store, { - window: target, - document: target.document, - }); - badges = new GptDiagnosticsBadgeManager(store, bindings, { - window: target, - document: target.document, - }); - overlay = new GptDiagnosticsOverlay(store, bindings, { - window: target, - document: target.document, - onExport: () => apiController?.api.export(), - onBadgeLayerChange: (layer) => badges?.setLayer(layer), - }); - apiController = new GptDiagnosticsApiController(store, bindings, overlay, { - window: target, - document: target.document, - }); + const observer = new GptDiagnosticsObserver(store); + let releaseFacts: (() => void) | undefined; + let bindings: GptDiagnosticsBindingManager | undefined; + let badges: GptDiagnosticsBadgeManager | undefined; + let overlay: GptDiagnosticsOverlay | undefined; + let apiController: GptDiagnosticsApiController | undefined; + + const cleanup = (): void => { + isolate(() => releaseFacts?.()); + isolate(() => apiController?.destroy()); + isolate(() => overlay?.destroy()); + isolate(() => badges?.destroy()); + isolate(() => bindings?.destroy()); + }; + + try { + observer.start(); + releaseFacts = facts.activate((fact) => observer.consume(fact)); + if (!releaseFacts) throw new Error('GPT diagnostics fact consumer is unavailable'); + bindings = new GptDiagnosticsBindingManager(store, { + window: targetWindow, + document: targetDocument, + }); + badges = new GptDiagnosticsBadgeManager(store, bindings, { + window: targetWindow, + document: targetDocument, + }); + overlay = new GptDiagnosticsOverlay(store, bindings, { + window: targetWindow, + document: targetDocument, + onExport: () => apiController?.api.export(), + onBadgeLayerChange: (layer) => badges?.setLayer(layer), + }); + apiController = new GptDiagnosticsApiController(store, bindings, overlay, { + window: targetWindow, + document: targetDocument, + }); + } catch (error) { + cleanup(); + throw error; + } - observer.install(); const api = apiController.api; - const runtime: GptDiagnosticsRuntime = { - api, - destroy: () => { - if (target.tsjs?.gptDiagnostics === api) delete target.tsjs.gptDiagnostics; - apiController?.destroy(); - overlay?.destroy(); - badges?.destroy(); - bindings?.destroy(); - delete target.__tsjs_gpt_diagnostics_runtime; - }, + let released = false; + const release = (): void => { + if (released) return; + released = true; + if (active?.release === release) active = undefined; + cleanup(); }; - target.tsjs.gptDiagnostics = api; - target.__tsjs_gpt_diagnostics_runtime = runtime; - return api; - } catch (error) { - apiController?.destroy(); - overlay?.destroy(); - badges?.destroy(); - bindings?.destroy(); - log.warn('gpt diagnostics: runtime installation failed', error); - return undefined; - } -} + active = Object.freeze({ api, release }); + return release; + }; -if (typeof window !== 'undefined' && isGptDiagnosticsActive(window as GptDiagnosticsWindow)) { - installGptDiagnosticsRuntime(window as GptDiagnosticsWindow); + return Object.freeze({ + activate, + currentApi: (): GptDiagnosticsApi | undefined => active?.api, + }); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts new file mode 100644 index 000000000..7c928376b --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts @@ -0,0 +1,91 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../kernel/integration_registry'; + +import type { GptDiagnosticsRuntime } from './index'; + +export const GPT_DIAGNOSTICS_INTEGRATION_ID = 'gpt_diagnostics' as const; + +function activeConfiguration(candidate: unknown): boolean { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Reflect.ownKeys(candidate).length !== 1 + ) { + return false; + } + const active = Object.getOwnPropertyDescriptor(candidate, 'active'); + return Boolean(active?.enumerable && 'value' in active && active.value === true); + } catch { + return false; + } +} + +function diagnosticsRuntime( + interfaces: Readonly> +): GptDiagnosticsRuntime | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, GPT_DIAGNOSTICS_INTEGRATION_ID); + if (!descriptor || !('value' in descriptor)) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 2 + ) { + return undefined; + } + const activate = Object.getOwnPropertyDescriptor(candidate, 'activate'); + const currentApi = Object.getOwnPropertyDescriptor(candidate, 'currentApi'); + if ( + !activate?.enumerable || + !('value' in activate) || + typeof activate.value !== 'function' || + !currentApi?.enumerable || + !('value' in currentApi) || + typeof currentApi.value !== 'function' + ) { + return undefined; + } + return candidate as GptDiagnosticsRuntime; + } catch { + return undefined; + } +} + +/** Builds the release-bound GPT diagnostics module for the coordinated runtime. */ +export function createGptDiagnosticsIntegrationRegistration( + release: string +): IntegrationRegistration { + return Object.freeze({ + id: GPT_DIAGNOSTICS_INTEGRATION_ID, + release, + prepare: ({ config, interfaces }: IntegrationPrepareContext) => { + if (!activeConfiguration(config)) { + throw new TypeError('GPT diagnostics integration config is invalid'); + } + const runtime = diagnosticsRuntime(interfaces); + if (!runtime) throw new TypeError('GPT diagnostics integration runtime is unavailable'); + + return Object.freeze({ + activate: ({ onDispose }: IntegrationActivationContext) => { + const ownership: { release?: () => void } = {}; + onDispose(() => ownership.release?.()); + const releaseRuntime = runtime.activate(); + if (typeof releaseRuntime !== 'function') { + throw new TypeError('GPT diagnostics integration disposer is unavailable'); + } + ownership.release = releaseRuntime; + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts index 12c5d64ae..472ee30a6 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts @@ -1,3 +1,4 @@ +import type { GoogletagDiagnosticsFact } from '../../adapters/googletag'; import { log } from '../../core/log'; import type { Size } from '../../core/types'; @@ -13,161 +14,79 @@ export interface GptDiagnosticsObserverStore { recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void; } -interface GptEvent { - slot: GptDiagnosticsSlotLike; -} - -interface GptRenderEvent extends GptEvent { - isEmpty?: boolean | undefined; - size?: unknown; - isBackfill?: boolean | undefined; - slotContentChanged?: boolean | undefined; -} - -interface GptVisibilityEvent extends GptEvent { - inViewPercentage: number; -} - -type GptEventName = - | 'slotRequested' - | 'slotResponseReceived' - | 'slotRenderEnded' - | 'slotOnload' - | 'impressionViewable' - | 'slotVisibilityChanged'; - -type GptEventListener = (event: GptEvent) => void; - -interface GptPubAdsService { - addEventListener(name: GptEventName, listener: GptEventListener): void; -} - -interface GptCommandQueue { - push(...callbacks: Array<() => void>): number; -} - -interface GoogletagLike { - cmd: GptCommandQueue; - pubads?: (() => GptPubAdsService) | undefined; -} - -export interface GptObserverWindow { - googletag?: GoogletagLike | undefined; -} - interface ObserverLogger { - warn(...args: unknown[]): void; + warn(...args: unknown[]): unknown; } interface ObserverOptions { - window?: GptObserverWindow | undefined; - logger?: ObserverLogger | undefined; -} - -function normalizeSize(value: unknown): Size | undefined { - if ( - !Array.isArray(value) || - value.length !== 2 || - typeof value[0] !== 'number' || - typeof value[1] !== 'number' || - !Number.isFinite(value[0]) || - !Number.isFinite(value[1]) - ) { - return undefined; - } - - return [value[0], value[1]]; + readonly logger?: ObserverLogger | undefined; } -/** Installs documented GPT event listeners through `googletag.cmd`. */ +/** Consumes normalized facts from the sole GPT adapter without owning browser-global access. */ export class GptDiagnosticsObserver { private readonly store: GptDiagnosticsObserverStore; - private readonly window: GptObserverWindow; private readonly logger: ObserverLogger; - private queued = false; - private installed = false; + private started = false; constructor(store: GptDiagnosticsObserverStore, options: ObserverOptions = {}) { this.store = store; - this.window = options.window ?? (window as unknown as GptObserverWindow); this.logger = options.logger ?? log; } - install(): void { - if (this.queued || this.installed) return; - this.queued = true; - - try { - const googletag = (this.window.googletag ??= { cmd: [] }); - googletag.cmd ??= []; - googletag.cmd.push(() => this.installWhenReady(googletag)); - } catch (error) { - this.queued = false; - this.logger.warn('gpt diagnostics: command queue installation failed', error); - } + start(): void { + if (this.started) return; + this.started = true; + this.handle('activation', () => this.store.markGptObserved()); } - private installWhenReady(googletag: GoogletagLike): void { - if (this.installed) return; - - try { - const pubads = googletag.pubads?.(); - if (!pubads || typeof pubads.addEventListener !== 'function') { - this.logger.warn('gpt diagnostics: PubAdsService unavailable'); + consume(fact: Readonly): void { + this.start(); + const slot = fact.slot as GptDiagnosticsSlotLike; + switch (fact.kind) { + case 'slotRequested': + this.handle(fact.kind, () => this.store.recordSlotRequested(slot)); return; - } - - pubads.addEventListener('slotRequested', (event) => { - this.handle('slotRequested', () => this.store.recordSlotRequested(event.slot)); - }); - pubads.addEventListener('slotResponseReceived', (event) => { - this.handle('slotResponseReceived', () => - this.store.recordSlotResponseReceived(event.slot) + case 'slotResponseReceived': + this.handle(fact.kind, () => this.store.recordSlotResponseReceived(slot)); + return; + case 'slotRenderEnded': + this.handle(fact.kind, () => + this.store.recordSlotRenderEnded(slot, { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + }) ); - }); - pubads.addEventListener('slotRenderEnded', (event) => { - this.handle('slotRenderEnded', () => { - const renderEvent = event as GptRenderEvent; - this.store.recordSlotRenderEnded(renderEvent.slot, { - isEmpty: typeof renderEvent.isEmpty === 'boolean' ? renderEvent.isEmpty : undefined, - size: normalizeSize(renderEvent.size), - isBackfill: - typeof renderEvent.isBackfill === 'boolean' ? renderEvent.isBackfill : undefined, - slotContentChanged: - typeof renderEvent.slotContentChanged === 'boolean' - ? renderEvent.slotContentChanged - : undefined, - }); - }); - }); - pubads.addEventListener('slotOnload', (event) => { - this.handle('slotOnload', () => this.store.recordSlotOnload(event.slot)); - }); - pubads.addEventListener('impressionViewable', (event) => { - this.handle('impressionViewable', () => this.store.recordImpressionViewable(event.slot)); - }); - pubads.addEventListener('slotVisibilityChanged', (event) => { - this.handle('slotVisibilityChanged', () => { - const visibilityEvent = event as GptVisibilityEvent; + return; + case 'slotOnload': + this.handle(fact.kind, () => this.store.recordSlotOnload(slot)); + return; + case 'impressionViewable': + this.handle(fact.kind, () => this.store.recordImpressionViewable(slot)); + return; + case 'slotVisibilityChanged': + this.handle(fact.kind, () => this.store.recordSlotVisibilityChanged( - visibilityEvent.slot, - visibilityEvent.inViewPercentage - ); - }); - }); - - this.installed = true; - this.store.markGptObserved(); - } catch (error) { - this.logger.warn('gpt diagnostics: listener installation failed', error); + slot, + typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN + ) + ); } } - private handle(kind: GptEventName, callback: () => void): void { + private handle( + kind: GoogletagDiagnosticsFact['kind'] | 'activation', + callback: () => void + ): void { try { callback(); } catch (error) { - this.logger.warn(`gpt diagnostics: ${kind} callback failed`, error); + try { + this.logger.warn(`gpt diagnostics: ${kind} callback failed`, error); + } catch { + // Diagnostics logging cannot escape into adapter fact delivery. + } } } } diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index c1e846e34..c5af73d33 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -5,6 +5,7 @@ import { createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, + type GoogletagDiagnosticsObserver, type GoogletagFacade, } from '../../src/adapters/googletag'; import { @@ -32,6 +33,7 @@ import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; +import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; @@ -63,6 +65,7 @@ function synchronousGptAdapter() { const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); const refresh = vi.fn(); + let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, clearTargeting: vi.fn((slot: object, key?: string) => { @@ -96,6 +99,13 @@ function synchronousGptAdapter() { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + if (diagnosticsObserver) return undefined; + diagnosticsObserver = observer; + return () => { + if (diagnosticsObserver === observer) diagnosticsObserver = undefined; + }; + }, observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => Value) => { let result: Promise; @@ -110,8 +120,25 @@ function synchronousGptAdapter() { return { adapter, emit: (eventType: string, event: unknown): void => { - for (const listener of listeners.get(eventType) ?? []) listener(event); + for (const listener of listeners.get(eventType) ?? []) { + listener(event); + if (typeof event !== 'object' || event === null || !('slot' in event)) continue; + diagnosticsObserver?.( + Object.freeze({ + ...event, + kind: eventType, + slot: event.slot, + }) as Parameters[0] + ); + } }, + diagnosticsObserverActive: () => diagnosticsObserver !== undefined, + listenerInventory: () => + Object.freeze( + [...listeners.entries()] + .filter(([, registered]) => registered.size > 0) + .map(([eventType, registered]) => Object.freeze([eventType, registered.size] as const)) + ), refresh, }; } @@ -521,6 +548,89 @@ describe('browser composition', () => { expect(listener).not.toHaveBeenCalled(); }); + it.each([false, true])( + 'installs only the active GPT diagnostics fact path when boot active is %s', + async (active) => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: active ? [{ id: 'gpt_diagnostics', required: true }] : [], + }, + knownIntegrationIds: active ? Object.freeze(['gpt_diagnostics']) : Object.freeze([]), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'boot', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + if (active) { + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + } + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const inventory = Object.fromEntries(gpt.listenerInventory()); + expect(inventory).toEqual( + active + ? { + impressionViewable: 1, + slotOnload: 1, + slotRenderEnded: 1, + slotRequested: 1, + slotResponseReceived: 1, + slotVisibilityChanged: 1, + } + : { slotRenderEnded: 1, slotRequested: 1 } + ); + expect(gpt.diagnosticsObserverActive()).toBe(active); + const diagnostics = target['diagnostics'] as + { readonly gpt?: { snapshot(): { slots: readonly unknown[] } } } | undefined; + expect(Reflect.ownKeys(diagnostics ?? {}).sort()).toEqual( + active ? ['gpt', 'renderTrace'] : ['renderTrace'] + ); + + if (active) { + const observedSlot = Object.freeze({ + getSlotElementId: () => 'diagnostic-slot', + getAdUnitPath: () => '/diagnostic/slot', + }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false, size: [300, 250] }); + expect(diagnostics?.gpt?.snapshot().slots).toHaveLength(1); + } + + composition.runtime.dispose(); + expect(gpt.diagnosticsObserverActive()).toBe(false); + } + ); + it('activates reversible core effects in exact order and disposes them in reverse', async () => { const target = {}; const order: string[] = []; @@ -635,7 +745,7 @@ describe('browser composition', () => { expect(diagnostics?.renderTrace?.history()).toEqual([]); }); - it('starts slot listeners before post-commit GPT startup and disposes both listeners', async () => { + it('starts core slot listeners before module activation and disposes both listeners', async () => { const releaseId = 'a'.repeat(64); const subscriptions: string[] = []; const releases: string[] = []; @@ -650,6 +760,7 @@ describe('browser composition', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { const result = Promise.resolve(command(facade)); @@ -662,7 +773,7 @@ describe('browser composition', () => { _adapters: unknown, services: { readonly slots: { readonly snapshotForTest: () => { records: number } } } ) => { - expect(subscriptions).toEqual([]); + expect(subscriptions).toEqual(['slotRequested', 'slotRenderEnded']); expect(services.slots.snapshotForTest().records).toBe(0); } ); @@ -715,6 +826,90 @@ describe('browser composition', () => { expect(releases).toEqual(['slotRenderEnded', 'slotRequested']); }); + it('activates one six-fact GPT diagnostics stream and publishes only diagnostics.gpt', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + expect(gpt.diagnosticsObserverActive()).toBe(true); + expect( + [...gpt.listenerInventory()].sort(([left], [right]) => left.localeCompare(right)) + ).toEqual([ + ['impressionViewable', 1], + ['slotOnload', 1], + ['slotRenderEnded', 1], + ['slotRequested', 1], + ['slotResponseReceived', 1], + ['slotVisibilityChanged', 1], + ]); + const diagnostics = target['diagnostics'] as + | { + readonly gpt?: { + snapshot(): { readonly slots: readonly { readonly slotElementId?: string }[] }; + }; + readonly renderTrace?: object; + } + | undefined; + expect(Reflect.ownKeys(diagnostics ?? {}).sort()).toEqual(['gpt', 'renderTrace']); + expect(Reflect.ownKeys(diagnostics?.gpt ?? {}).sort()).toEqual( + ['export', 'hide', 'show', 'snapshot', 'subscribe'].sort() + ); + expect(diagnostics).not.toHaveProperty('publish'); + expect(target['gptDiagnostics']).toBeUndefined(); + expect(target['__tsjs_gpt_diagnostics_runtime']).toBeUndefined(); + + const observedSlot = Object.freeze({ + getSlotElementId: () => 'composition-slot', + getAdUnitPath: () => '/example/composition-slot', + }); + gpt.emit('slotRequested', { slot: observedSlot }); + gpt.emit('slotResponseReceived', { slot: observedSlot }); + expect(diagnostics?.gpt?.snapshot().slots[0]?.slotElementId).toBe('composition-slot'); + + composition.runtime.dispose(); + await Promise.resolve(); + expect(gpt.diagnosticsObserverActive()).toBe(false); + expect(gpt.listenerInventory()).toEqual([]); + }); + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; diff --git a/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs b/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs index 1770c5613..95c0667d9 100644 --- a/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs +++ b/crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs @@ -222,7 +222,6 @@ test('permits external-global ownership only in adapter source files', () => { test('temporary allowlists are exact, narrow, and inventoried for Task 22 removal', () => { assert.deepEqual(LEGACY_ADTECH_GLOBAL_ALLOWLIST, [ 'src/integrations/gpt/index.ts', - 'src/integrations/gpt_diagnostics/observer.ts', 'src/integrations/prebid/index.ts', ]); assert.deepEqual(LEGACY_RESTRICTED_IMPORT_ALLOWLIST, [ diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts index 085d89e0e..dd21c36b8 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import type { GoogletagAdapter, @@ -16,6 +16,12 @@ function fact(index: number): Readonly { } describe('GPT diagnostics fact transport', () => { + it('requires diagnostics observation on every GPT adapter', () => { + expectTypeOf().toMatchTypeOf<{ + observeDiagnostics(observer: GoogletagDiagnosticsObserver): (() => void) | undefined; + }>(); + }); + it('buffers 512 facts, evicts the oldest, replays in order, then releases the buffer', () => { const buffer = createGptDiagnosticsFactBuffer(); for (let index = 0; index < 513; index += 1) expect(buffer.publish(fact(index))).toBe(true); @@ -95,10 +101,23 @@ describe('GPT diagnostics fact transport', () => { expect(subscriptions.sort()).toEqual( ['impressionViewable', 'slotOnload', 'slotResponseReceived', 'slotVisibilityChanged'].sort() ); - dispose(); - dispose(); + dispose?.(); + dispose?.(); expect(operationDispose).toHaveBeenCalledOnce(); expect(releases.every((release) => release.mock.calls.length === 1)).toBe(true); expect(observer).toBeUndefined(); }); + + it('rejects capture when another diagnostics observer owns the adapter', () => { + const run = vi.fn(); + const adapter = Object.freeze({ + observeDiagnostics: () => undefined, + run, + }) as unknown as Pick; + + expect( + activateGptDiagnosticsFactCapture(adapter, createGptDiagnosticsFactBuffer()) + ).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index 5badf7638..8f9ebc7f2 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { LegacyTsjsApi } from '../../../src/core/types'; -import { - installGptDiagnosticsRuntime, - isGptDiagnosticsActive, -} from '../../../src/integrations/gpt_diagnostics'; +import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; +import { createGptDiagnosticsFactBuffer } from '../../../src/integrations/gpt_diagnostics/facts'; +import { createGptDiagnosticsRuntime } from '../../../src/integrations/gpt_diagnostics'; import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; interface FakeSlot { @@ -12,58 +10,19 @@ interface FakeSlot { getAdUnitPath(): string; } -type Listener = (event: unknown) => void; - -type DiagnosticsTestWindow = NonNullable[0]>; - -const target = window as unknown as DiagnosticsTestWindow; - -function coreApi(): LegacyTsjsApi { - return { - version: 'test', - que: [], - addAdUnits: vi.fn(), - renderAdUnit: vi.fn(), - renderAllAdUnits: vi.fn(), - }; -} - -function installGptStub() { - const listeners = new Map(); - const addEventListener = vi.fn((name: string, listener: Listener) => { - const existing = listeners.get(name) ?? []; - existing.push(listener); - listeners.set(name, existing); - }); - const queue = { - push: vi.fn((callback: () => void) => { - callback(); - return 1; - }), - }; - target.googletag = { - cmd: queue, - pubads: () => ({ addEventListener }), - }; - return { - addEventListener, - queue, - emit(name: string, event: Record) { - for (const listener of listeners.get(name) ?? []) listener(event); - }, - }; -} - function slot(id: string): FakeSlot { - return { + return Object.freeze({ getSlotElementId: () => id, getAdUnitPath: () => `/example/site/${id}`, - }; + }); } -async function settle(): Promise { - await Promise.resolve(); - await Promise.resolve(); +function fact( + kind: GoogletagDiagnosticsFact['kind'], + observedSlot: object, + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, slot: observedSlot, ...fields }); } beforeEach(() => { @@ -77,155 +36,87 @@ beforeEach(() => { configurable: true, value: { escape: (value: string) => value }, }); - target.tsjs = coreApi(); - delete target.googletag; - delete target.__tsjs_gpt_diagnostics_active; - delete target.__tsjs_gpt_diagnostics_runtime; }); afterEach(() => { - target.__tsjs_gpt_diagnostics_runtime?.destroy(); - delete target.__tsjs_gpt_diagnostics_active; - delete target.__tsjs_gpt_diagnostics_runtime; - delete target.googletag; - delete target.tsjs; vi.unstubAllGlobals(); vi.restoreAllMocks(); document.body.replaceChildren(); }); -describe('GPT diagnostics integration composition', () => { - it('has no inactive side effects', () => { - const originalMutationObserver = window.MutationObserver; - - expect(isGptDiagnosticsActive(target)).toBe(false); - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); +describe('GPT diagnostics runtime', () => { + it('is inert until activation and publishes no legacy global or mutable authority', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const legacyTarget = window as unknown as Record; - expect(target.tsjs?.gptDiagnostics).toBeUndefined(); - expect(target.googletag).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); + expect(runtime.currentApi()).toBeUndefined(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); - expect(window.MutationObserver).toBe(originalMutationObserver); - }); - it('installs one idempotent active runtime and six listeners', () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const previousApi = target.tsjs; - - const first = installGptDiagnosticsRuntime(target); - const second = installGptDiagnosticsRuntime(target); - - expect(first).toBeDefined(); - expect(second).toBe(first); - expect(target.tsjs).toBe(previousApi); - expect(target.tsjs?.gptDiagnostics).toBe(first); - expect(gpt.queue.push).toHaveBeenCalledTimes(1); - expect(gpt.addEventListener).toHaveBeenCalledTimes(6); - expect(gpt.addEventListener.mock.calls.map(([name]) => name).sort()).toEqual( - [ - 'impressionViewable', - 'slotOnload', - 'slotRenderEnded', - 'slotRequested', - 'slotResponseReceived', - 'slotVisibilityChanged', - ].sort() + const release = runtime.activate(); + const api = runtime.currentApi(); + + expect(api).toBeDefined(); + expect(Object.isFrozen(api)).toBe(true); + expect(Reflect.ownKeys(api ?? {}).sort()).toEqual( + ['export', 'hide', 'show', 'snapshot', 'subscribe'].sort() + ); + expect(legacyTarget['__tsjs_gpt_diagnostics_active']).toBeUndefined(); + expect(legacyTarget['__tsjs_gpt_diagnostics_runtime']).toBeUndefined(); + expect((legacyTarget['tsjs'] as Record | undefined)?.['gptDiagnostics']).toBe( + undefined ); expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); + + expect(() => runtime.activate()).toThrow(/already active/i); + release(); + release(); + expect(runtime.currentApi()).toBeUndefined(); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); }); - it('keeps capture active while presentation is hidden', async () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const api = installGptDiagnosticsRuntime(target)!; + it('replays buffered facts and keeps capture active while presentation is hidden', () => { + const buffer = createGptDiagnosticsFactBuffer(); const observedSlot = slot('hidden-slot'); + buffer.publish(fact('slotRequested', observedSlot)); + buffer.publish(fact('slotResponseReceived', observedSlot)); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const release = runtime.activate(); + const api = runtime.currentApi(); + if (!api) throw new Error('Expected active diagnostics API'); api.hide(); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false, size: [300, 250] }); - await settle(); + buffer.publish( + fact('slotRenderEnded', observedSlot, { + isEmpty: false, + size: Object.freeze([300, 250]), + }) + ); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); - expect(api.snapshot().slots[0]!.requests).toHaveLength(1); - expect(api.snapshot().slots[0]!.requests[0]!.isEmpty).toBe(false); + expect(api.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestNumber: 1, + isEmpty: false, + size: [300, 250], + }); api.show(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); + release(); }); - it('keeps lifecycle, overlap issues, bindings, panel, and export snapshot consistent', async () => { - target.__tsjs_gpt_diagnostics_active = true; - const gpt = installGptStub(); - const element = document.createElement('div'); - element.id = 'lifecycle-slot'; - vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ - left: 20, - top: 100, - right: 320, - bottom: 350, - width: 300, - height: 250, - x: 20, - y: 100, - toJSON: () => ({}), - } as DOMRect); - document.body.append(element); - const api = installGptDiagnosticsRuntime(target)!; - const observedSlot = slot('lifecycle-slot'); - - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { - slot: observedSlot, - isEmpty: false, - size: [300, 250], - isBackfill: true, - }); - gpt.emit('slotOnload', { slot: observedSlot }); - gpt.emit('impressionViewable', { slot: observedSlot }); - gpt.emit('slotVisibilityChanged', { slot: observedSlot, inViewPercentage: 75 }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: true }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotRequested', { slot: observedSlot }); - gpt.emit('slotResponseReceived', { slot: observedSlot }); - await settle(); - - const snapshot = api.snapshot(); - expect(snapshot.slots).toHaveLength(1); - expect(snapshot.slots[0]).toMatchObject({ - slotElementId: 'lifecycle-slot', - adUnitPath: '/example/site/lifecycle-slot', - binding: { status: 'bound' }, - currentVisibilityPercentage: 75, - }); - expect(snapshot.slots[0]!.requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); - expect(snapshot.callbackIssues).toContainEqual( - expect.objectContaining({ - kind: 'slotResponseReceived', - disposition: 'ambiguous', - reason: 'overlapping_request_cycles', - }) - ); - expect(snapshot.coverage.slotResponseReceived.observed).toBe( - snapshot.coverage.slotResponseReceived.matched + - snapshot.coverage.slotResponseReceived.unmatched + - snapshot.coverage.slotResponseReceived.ambiguous - ); - expect(document.querySelector(`#${GPT_DIAGNOSTICS_HOST_ID}`)).not.toBeNull(); - expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); - expect(element.getAttributeNames()).toEqual(['id']); - }); + it('releases its consumer so replacement activation receives intervening buffered facts', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + const firstRelease = runtime.activate(); + firstRelease(); + const observedSlot = slot('replacement-slot'); + buffer.publish(fact('slotRequested', observedSlot)); - it('leaves no half-initialized API when the core API is unavailable', () => { - target.__tsjs_gpt_diagnostics_active = true; - delete target.tsjs; + const secondRelease = runtime.activate(); - expect(installGptDiagnosticsRuntime(target)).toBeUndefined(); - expect(target.__tsjs_gpt_diagnostics_runtime).toBeUndefined(); - expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + expect(runtime.currentApi()?.snapshot().slots[0]?.slotElementId).toBe('replacement-slot'); + secondRelease(); + buffer.dispose(); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts new file mode 100644 index 000000000..a0b5cce11 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/module.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createGptDiagnosticsIntegrationRegistration } from '../../../src/integrations/gpt_diagnostics/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest() { + return { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }; +} + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional GPT diagnostics integration module', () => { + it('prepares inertly, activates before publication, and releases exactly once', async () => { + const order: string[] = []; + const release = vi.fn(() => order.push('release')); + const activate = vi.fn(() => { + order.push('diagnostics:activate'); + return release; + }); + const runtime = Object.freeze({ activate, currentApi: vi.fn() }); + const registry = createIntegrationRegistry({ + manifest: manifest(), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ active: true }), + interfaces: Object.freeze({ gpt_diagnostics: runtime }), + }), + }); + registry.register(createGptDiagnosticsIntegrationRegistration(RELEASE_ID)); + + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'diagnostics:activate', 'publish', 'drain']); + expect(activate).toHaveBeenCalledOnce(); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + ['inactive', Object.freeze({ active: false })], + ['extra field', Object.freeze({ active: true, legacy: true })], + ['mutable', { active: true }], + ['missing', Object.freeze({})], + ])('rejects %s configuration without activating', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: manifest(), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + gpt_diagnostics: Object.freeze({ activate, currentApi: vi.fn() }), + }), + }), + }); + registry.register(createGptDiagnosticsIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('rejects a forged composition runtime during inert preparation', async () => { + const registry = createIntegrationRegistry({ + manifest: manifest(), + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: Object.freeze({ active: true }), + interfaces: Object.freeze({ + gpt_diagnostics: Object.freeze({ activate: vi.fn(), currentApi: vi.fn(), extra: true }), + }), + }), + }); + registry.register(createGptDiagnosticsIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts index b1b8fc95d..ca934a177 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; +import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, } from '../../../src/integrations/gpt_diagnostics/observer'; import type { GptDiagnosticsSlotLike } from '../../../src/integrations/gpt_diagnostics/store'; -const EVENT_NAMES = [ - 'slotRequested', - 'slotResponseReceived', - 'slotRenderEnded', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', -] as const; - -type EventName = (typeof EVENT_NAMES)[number]; -type EventListener = (event: { slot: GptDiagnosticsSlotLike; [key: string]: unknown }) => void; - function fakeStore(): GptDiagnosticsObserverStore { return { markGptObserved: vi.fn(), @@ -31,145 +20,51 @@ function fakeStore(): GptDiagnosticsObserverStore { } function fakeSlot(): GptDiagnosticsSlotLike { - return { + return Object.freeze({ getSlotElementId: () => 'ad-slot-example', getAdUnitPath: () => '/example/site/banner', - }; -} - -function controlledGpt() { - const listeners = new Map(); - const addEventListener = vi.fn((name: EventName, listener: EventListener) => { - const current = listeners.get(name) ?? []; - current.push(listener); - listeners.set(name, current); }); - const pubads = { - addEventListener, - refresh: vi.fn(), - }; - const display = vi.fn(); - const defineSlot = vi.fn(); - const cmd: Array<() => void> = []; - const googletag = { - cmd, - pubads: () => pubads, - display, - defineSlot, - }; +} - return { - window: { googletag }, - googletag, - pubads, - listeners, - emit(name: EventName, event: Parameters[0]) { - for (const listener of listeners.get(name) ?? []) listener(event); - }, - }; +function fact( + kind: GoogletagDiagnosticsFact['kind'], + slot: object, + fields: Partial = {} +): Readonly { + return Object.freeze({ kind, slot, ...fields }); } describe('GptDiagnosticsObserver', () => { - it('installs exactly the six documented listeners through googletag.cmd', () => { + it('starts exactly once without reading or mutating any browser global', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); + const observer = new GptDiagnosticsObserver(store); - expect(gpt.googletag.cmd).toHaveLength(1); - expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); + observer.start(); + observer.start(); - gpt.googletag.cmd[0]!(); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - expect(gpt.pubads.addEventListener.mock.calls.map(([name]) => name)).toEqual(EVENT_NAMES); - expect(store.markGptObserved).toHaveBeenCalledTimes(1); + expect(store.markGptObserved).toHaveBeenCalledOnce(); }); - it('is idempotent before and after command queue execution', () => { + it('consumes all six normalized adapter facts', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - - observer.install(); - observer.install(); - expect(gpt.googletag.cmd).toHaveLength(1); - - gpt.googletag.cmd[0]!(); - observer.install(); - gpt.googletag.cmd[0]!(); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - expect(store.markGptObserved).toHaveBeenCalledTimes(1); - }); - - it('creates a command queue and waits when GPT is absent', () => { - const store = fakeStore(); - const delayedWindow: { - googletag?: { - cmd: Array<() => void>; - pubads?: () => { addEventListener: (name: EventName, listener: EventListener) => void }; - }; - } = {}; - const observer = new GptDiagnosticsObserver(store, { window: delayedWindow }); - - observer.install(); - - expect(delayedWindow.googletag?.cmd).toHaveLength(1); - const gpt = controlledGpt(); - delayedWindow.googletag!.pubads = gpt.googletag.pubads; - delayedWindow.googletag!.cmd[0]!(); - - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - }); - - it('preserves an already-loaded custom command push contract', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const callbacks: Array<() => void> = []; - const customPush = vi.fn((...next: Array<() => void>) => { - callbacks.push(...next); - for (const callback of next) callback(); - return callbacks.length; - }); - const observer = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { push: customPush }, - pubads: gpt.googletag.pubads, - }, - }, - }); - - observer.install(); - - expect(customPush).toHaveBeenCalledTimes(1); - expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); - }); - - it('normalizes allowed callback facts and forwards every event kind', () => { - const store = fakeStore(); - const gpt = controlledGpt(); const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0]!(); - - gpt.emit('slotRequested', { slot }); - gpt.emit('slotResponseReceived', { slot }); - gpt.emit('slotRenderEnded', { - slot, - isEmpty: false, - size: [300, 250], - isBackfill: true, - slotContentChanged: false, - creativeId: 'must-not-pass-through', - }); - gpt.emit('slotOnload', { slot }); - gpt.emit('impressionViewable', { slot }); - gpt.emit('slotVisibilityChanged', { slot, inViewPercentage: 42 }); + const observer = new GptDiagnosticsObserver(store); + + observer.consume(fact('slotRequested', slot)); + observer.consume(fact('slotResponseReceived', slot)); + observer.consume( + fact('slotRenderEnded', slot, { + isEmpty: false, + size: Object.freeze([300, 250]), + isBackfill: true, + slotContentChanged: false, + }) + ); + observer.consume(fact('slotOnload', slot)); + observer.consume(fact('impressionViewable', slot)); + observer.consume(fact('slotVisibilityChanged', slot, { inViewPercentage: 42 })); + expect(store.markGptObserved).toHaveBeenCalledOnce(); expect(store.recordSlotRequested).toHaveBeenCalledWith(slot); expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot); expect(store.recordSlotRenderEnded).toHaveBeenCalledWith(slot, { @@ -183,98 +78,32 @@ describe('GptDiagnosticsObserver', () => { expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); }); - it('drops unsupported or invalid rendered sizes', () => { + it('records a malformed visibility fact as unmatched instead of dropping its coverage', () => { const store = fakeStore(); - const gpt = controlledGpt(); - const slot = fakeSlot(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - observer.install(); - gpt.googletag.cmd[0]!(); + const observer = new GptDiagnosticsObserver(store); - gpt.emit('slotRenderEnded', { slot, isEmpty: false, size: 'fluid' }); + observer.consume(fact('slotVisibilityChanged', fakeSlot())); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( - slot, - expect.objectContaining({ size: undefined }) - ); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN); }); - it('contains callback and Slot accessor failures and warns', () => { + it('contains store and logger failures without interrupting later facts', () => { const store = fakeStore(); vi.mocked(store.recordSlotRequested).mockImplementation(() => { throw new Error('store failed'); }); - const logger = { warn: vi.fn() }; - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window, logger }); - observer.install(); - gpt.googletag.cmd[0]!(); - const event = { - get slot(): GptDiagnosticsSlotLike { - throw new Error('slot accessor failed'); - }, - }; - - expect(() => gpt.emit('slotRequested', { slot: fakeSlot() })).not.toThrow(); - expect(() => gpt.emit('slotOnload', event)).not.toThrow(); - expect(logger.warn).toHaveBeenCalledTimes(2); - }); - - it('contains command queue and listener installation failures', () => { - const store = fakeStore(); - const logger = { warn: vi.fn() }; - const queueObserver = new GptDiagnosticsObserver(store, { - window: { - googletag: { - cmd: { - push: () => { - throw new Error('queue failed'); - }, - }, - }, - }, - logger, - }); - - expect(() => queueObserver.install()).not.toThrow(); - - const gpt = controlledGpt(); - gpt.pubads.addEventListener.mockImplementation(() => { - throw new Error('listener failed'); - }); - const listenerObserver = new GptDiagnosticsObserver(store, { - window: gpt.window, - logger, - }); - listenerObserver.install(); - - expect(() => gpt.googletag.cmd[0]!()).not.toThrow(); - expect(logger.warn).toHaveBeenCalledTimes(2); - }); - - it('does not patch GPT or browser methods', () => { - const store = fakeStore(); - const gpt = controlledGpt(); - const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); - const references = { - display: gpt.googletag.display, - defineSlot: gpt.googletag.defineSlot, - refresh: gpt.pubads.refresh, - fetch: window.fetch, - XMLHttpRequest: window.XMLHttpRequest, - pushState: window.history.pushState, - replaceState: window.history.replaceState, + const logger = { + warn: vi.fn(() => { + throw new Error('logger failed'); + }), }; + const observer = new GptDiagnosticsObserver(store, { logger }); + const slot = fakeSlot(); - observer.install(); - gpt.googletag.cmd[0]!(); + expect(() => observer.consume(fact('slotRequested', slot))).not.toThrow(); + expect(() => observer.consume(fact('slotOnload', slot))).not.toThrow(); - expect(gpt.googletag.display).toBe(references.display); - expect(gpt.googletag.defineSlot).toBe(references.defineSlot); - expect(gpt.pubads.refresh).toBe(references.refresh); - expect(window.fetch).toBe(references.fetch); - expect(window.XMLHttpRequest).toBe(references.XMLHttpRequest); - expect(window.history.pushState).toBe(references.pushState); - expect(window.history.replaceState).toBe(references.replaceState); + expect(logger.warn).toHaveBeenCalledOnce(); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); }); }); diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 0c8be81c8..54485e8b6 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -142,6 +142,7 @@ function createGptHarness( bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let disposed = false; @@ -4297,6 +4298,7 @@ describe('Task 11 adversarial ownership review', () => { bindingStatus: () => 'present', dispose: vi.fn(), notifyReady: vi.fn(), + observeDiagnostics: () => vi.fn(), observePublisherCalls: () => vi.fn(), run: (command: (gpt: Readonly) => T) => { let value: T; From 81765844df8c46810b74690a795666f8f1057dec Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:22:20 -0700 Subject: [PATCH 370/494] Move diagnostics session state to the server --- .../trusted-server-core/src/html_processor.rs | 20 +-- .../src/integrations/gpt_diagnostics.rs | 137 ++++++++++++++---- .../integrations/gpt_diagnostics_bootstrap.js | 63 +------- crates/trusted-server-core/src/publisher.rs | 118 ++++++++++++++- .../trusted-server-core/src/trace_cookie.rs | 59 +++++++- .../gpt_diagnostics/bootstrap.test.ts | 123 +--------------- 6 files changed, 293 insertions(+), 227 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 4c827ace0..6728f9364 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -348,12 +348,6 @@ 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)); @@ -871,14 +865,13 @@ mod tests { .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" + processed.matches("__tsjs_gpt_diagnostics_active").count(), + 0, + "server boot data must be the only browser-visible activation result" ); assert_eq!( processed.matches(bundle_marker).count(), @@ -890,19 +883,12 @@ mod tests { 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" diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 350a09cf8..fd7ac0552 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -62,7 +62,7 @@ pub enum GptDiagnosticsCookieAction { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GptDiagnosticsRequestDecision { active: bool, - clean_browser_path_and_query: Option, + reserved_directive: bool, cookie_action: GptDiagnosticsCookieAction, } @@ -73,33 +73,22 @@ impl GptDiagnosticsRequestDecision { self.active } + /// Serialize the exact `DiagnosticsBootV1.gpt` value for the boot emitter. + #[must_use] + pub fn boot_config_json(&self) -> &'static str { + if self.active { + r#"{"active":true}"# + } else { + r#"{"active":false}"# + } + } + /// 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) + || self.reserved_directive } /// Build the synchronous standalone diagnostics module tag. @@ -183,9 +172,11 @@ pub fn prepare_request( replace_path_and_query(request, &clean_path)?; } - let mut decision = GptDiagnosticsRequestDecision::default(); + let mut decision = GptDiagnosticsRequestDecision { + reserved_directive: had_reserved_query, + ..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; @@ -224,6 +215,7 @@ pub fn finalize_response( decision: &GptDiagnosticsRequestDecision, response: &mut Response, ) { + sanitize_console_set_cookie(response); let cookie = match decision.cookie_action { GptDiagnosticsCookieAction::None => None, GptDiagnosticsCookieAction::SetSession => { @@ -284,9 +276,9 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState { for cookie in value.split(';') { let cookie = cookie.trim(); match cookie.split_once('=') { - Some((name, value)) if name.trim() == GPT_DIAGNOSTICS_COOKIE => { + Some((name, value)) if name == GPT_DIAGNOSTICS_COOKIE => { state.occurrences += 1; - state.canonical |= value.trim() == "1"; + state.canonical |= value == "1"; } None if cookie == GPT_DIAGNOSTICS_COOKIE => state.occurrences += 1, _ => {} @@ -296,6 +288,33 @@ fn console_cookie_state(request: &Request) -> ConsoleCookieState { state } +fn sanitize_console_set_cookie(response: &mut Response) { + let retained = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter(|value| { + let pair = value + .as_bytes() + .split(|byte| *byte == b';') + .next() + .unwrap_or_default(); + let name = pair + .split(|byte| *byte == b'=') + .next() + .unwrap_or_default() + .trim_ascii(); + name != GPT_DIAGNOSTICS_COOKIE.as_bytes() + }) + .cloned() + .collect::>(); + + response.headers_mut().remove(header::SET_COOKIE); + for value in retained { + response.headers_mut().append(header::SET_COOKIE, value); + } +} + fn sanitize_console_cookie(request: &mut Request) { let retained = request .headers() @@ -406,9 +425,7 @@ mod tests { "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")); + assert_eq!(decision.boot_config_json(), r#"{"active":true}"#); } #[test] @@ -445,6 +462,13 @@ mod tests { let decision = prepare_request(&settings(true), &mut duplicate).expect("should prepare"); assert!(!decision.active()); assert_eq!(duplicate.headers()[header::COOKIE], "other=value"); + + for noncanonical in ["__Host-ts-console =1", "__Host-ts-console= 1"] { + let mut request = navigation("https://publisher.example/page", Some(noncanonical)); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + assert!(!decision.active(), "{noncanonical} must fail closed"); + assert!(!request.headers().contains_key(header::COOKIE)); + } } #[test] @@ -476,6 +500,46 @@ mod tests { ); } + #[test] + fn ts_console_invalid_directive_is_private_without_mutating_the_session() { + let mut request = navigation( + "https://publisher.example/page?keep=1&ts_console=True", + Some("__Host-ts-console=1"), + ); + let decision = prepare_request(&settings(true), &mut request).expect("should prepare"); + let mut response = Response::builder() + .header(header::CACHE_CONTROL, "public, max-age=60") + .body(EdgeBody::empty()) + .expect("should build response"); + + finalize_response(&decision, &mut response); + + assert!(!decision.active()); + assert_eq!(request.uri().query(), Some("keep=1")); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + } + + #[test] + fn ts_console_is_disabled_by_default_while_reserved_input_is_still_sanitized() { + let mut request = navigation( + "https://publisher.example/page?keep=1&ts_console=1", + Some("__Host-ts-console=1; other=value"), + ); + + let decision = prepare_request(&settings(false), &mut request).expect("should prepare"); + + assert!(!decision.active()); + assert_eq!(decision.boot_config_json(), r#"{"active":false}"#); + assert_eq!(decision.cookie_action, GptDiagnosticsCookieAction::None); + assert_eq!(request.uri().query(), Some("keep=1")); + assert_eq!(request.headers()[header::COOKIE], "other=value"); + assert!(decision.requires_private_no_store()); + } + #[test] fn ts_console_finalization_sets_cookie_and_strips_shared_cache_headers() { let mut request = navigation("https://publisher.example/?ts_console=1", None); @@ -485,6 +549,11 @@ mod tests { .header("surrogate-control", "max-age=60") .header("fastly-surrogate-control", "max-age=60") .header("cloudflare-cdn-cache-control", "public, max-age=60") + .header(header::SET_COOKIE, "publisher=value; Path=/") + .header( + header::SET_COOKIE, + "__Host-ts-console=origin; Path=/; Secure", + ) .body(EdgeBody::empty()) .expect("should build response"); @@ -494,7 +563,13 @@ mod tests { response.headers()[header::CACHE_CONTROL], "private, no-store" ); - assert_eq!(response.headers()[header::SET_COOKIE], SET_CONSOLE_COOKIE); + let cookies = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should emit valid cookie text")) + .collect::>(); + assert_eq!(cookies, vec!["publisher=value; Path=/", SET_CONSOLE_COOKIE]); assert!(!response.headers().contains_key("surrogate-control")); assert!(!response.headers().contains_key("fastly-surrogate-control")); assert!( diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js index a857fc984..dde2197f8 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js @@ -1,59 +1,4 @@ -// Early activation bootstrap for the GPT diagnostics integration. -// -// This script intentionally owns only tab-local activation and one-time URL -// cleanup. The TypeScript integration reads the document flag below and owns -// all GPT observation, storage, API, and presentation behavior. -(function () { - if (typeof window === "undefined") return; - - var queryName = "ts_console"; - var storageKey = "tsjs:gptDiagnostics:active"; - var activeFlag = "__tsjs_gpt_diagnostics_active"; - var active = false; - var directiveRecognized = false; - var url; - - try { - url = new URL(window.location.href); - var value = url.searchParams.get(queryName); - - if (value === "1" || value === "true") { - active = true; - directiveRecognized = true; - } else if (value === "0" || value === "false") { - active = false; - directiveRecognized = true; - } - } catch (_) { - url = undefined; - } - - if (directiveRecognized) { - try { - window.sessionStorage.setItem(storageKey, active ? "1" : "0"); - } catch (_) { - // The recognized directive still applies to this document. - } - - if (url) { - url.searchParams.delete(queryName); - try { - window.history.replaceState( - window.history.state, - "", - url.pathname + url.search + url.hash, - ); - } catch (_) { - // URL cleanup is optional and must not block diagnostics activation. - } - } - } else { - try { - active = window.sessionStorage.getItem(storageKey) === "1"; - } catch (_) { - active = false; - } - } - - window[activeFlag] = active; -})(); +// GPT diagnostics activation is server-owned and is transported only through +// the validated, frozen diagnostics boot value. This intentionally has no +// browser-side activation behavior and remains only until the wiring cutover +// removes the superseded asset. diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index cd1852162..4595166dd 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2964,7 +2964,7 @@ pub async fn handle_publisher_request( .await; } - let response = Response::builder() + let mut response = Response::builder() .status(StatusCode::BAD_GATEWAY) .header(header::CACHE_CONTROL, "private, no-store") .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") @@ -2974,6 +2974,7 @@ pub async fn handle_publisher_request( .change_context(TrustedServerError::Proxy { message: "failed to build unexpected origin 304 response".to_string(), })?; + crate::integrations::gpt_diagnostics::finalize_response(&gpt_diagnostics, &mut response); return Ok(PublisherResponse::Buffered(response)); } @@ -5182,8 +5183,8 @@ mod tests { let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); assert!( - html.contains("__tsjs_gpt_diagnostics_active"), - "should inject the activation flag" + !html.contains("__tsjs_gpt_diagnostics_active"), + "should not inject the removed activation flag" ); assert!( html.contains("tsjs-gpt_diagnostics.min.js"), @@ -5873,6 +5874,54 @@ mod tests { } } + #[tokio::test] + async fn ts_console_finalizes_session_on_replaced_origin_304_response() { + let mut settings = settings_with_enabled_auction_and_creative_opportunities(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 304, + Vec::new(), + vec![ + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let slots = [article_slot()]; + let mut req = conditional_navigation_request(); + *req.uri_mut() = "https://ts.example.com/article?keep=1&ts_console=1" + .parse() + .expect("should parse activation URI"); + + let response = run_with_slots(&settings, &services, &slots, req).await; + let response = match response { + PublisherResponse::Buffered(response) => response, + PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { + panic!("unexpected origin 304 should return a buffered response") + } + }; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + response.headers()[header::SET_COOKIE], + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_eq!( + stub.recorded_request_uris(), + vec!["https://origin.test-publisher.com/article?keep=1"] + ); + } + #[tokio::test] async fn noneligible_origin_304_preserves_conditional_response_metadata() { // Arrange @@ -5986,6 +6035,69 @@ mod tests { ); } + #[tokio::test] + async fn ts_console_publisher_pipeline_strips_reserved_input_and_finalizes_session() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("surrogate-control", "max-age=300"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article?keep=%2F&ts_console=true") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .header( + header::COOKIE, + "other=value; __Host-ts-console=1; second=two", + ) + .body(EdgeBody::empty()) + .expect("should build diagnostics navigation"); + + let response = run_publisher_proxy(&settings, &services, req).await; + let headers = match response { + PublisherResponse::Buffered(response) + | PublisherResponse::PassThrough { response, .. } + | PublisherResponse::Stream { response, .. } => response.into_parts().0.headers, + }; + + let origin_uri = stub + .recorded_request_uris() + .into_iter() + .next() + .expect("should forward one publisher request"); + assert!(origin_uri.contains("keep=%2F")); + assert!(!origin_uri.contains("ts_console")); + let outbound_headers = stub.recorded_request_headers(); + let outbound_cookies = outbound_headers + .first() + .expect("should record publisher request headers") + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case(header::COOKIE.as_str())) + .map(|(_, value)| value.as_str()) + .collect::>(); + assert_eq!(outbound_cookies, vec!["other=value; second=two"]); + assert_eq!( + headers[header::SET_COOKIE], + "__Host-ts-console=1; Path=/; Secure; HttpOnly; SameSite=Lax" + ); + assert_eq!(headers[header::CACHE_CONTROL], "private, no-store"); + assert!(!headers.contains_key("surrogate-control")); + } + #[tokio::test] async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/trace_cookie.rs b/crates/trusted-server-core/src/trace_cookie.rs index 583a96238..fdbc60c4c 100644 --- a/crates/trusted-server-core/src/trace_cookie.rs +++ b/crates/trusted-server-core/src/trace_cookie.rs @@ -13,7 +13,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use http::{HeaderValue, Response, StatusCode, header}; +use http::{HeaderValue, Request, Response, StatusCode, header}; use crate::constants::COOKIE_TS_TRACE; use crate::error::TrustedServerError; @@ -25,6 +25,32 @@ use crate::settings::Settings; /// navigations, short enough that a forgotten toggle expires on its own. const TRACE_COOKIE_MAX_AGE_SECS: u32 = 3600; +/// Resolve the server-owned render-trace overlay bit for `DiagnosticsBootV1`. +/// +/// Only the exact cookie emitted by [`handle_trace_mode`] activates the overlay. +/// Duplicate reserved cookies fail closed so request header ordering cannot +/// choose the browser-visible diagnostics state. +#[must_use] +pub fn render_trace_overlay_active(request: &Request) -> bool { + let mut occurrences = 0_usize; + let mut active = false; + for value in request.headers().get_all(header::COOKIE) { + let Ok(value) = value.to_str() else { + return false; + }; + for cookie in value.split(';').map(str::trim) { + let Some((name, value)) = cookie.split_once('=') else { + continue; + }; + if name == COOKIE_TS_TRACE { + occurrences += 1; + active = value == "1"; + } + } + } + occurrences == 1 && active +} + /// Formats the trace cookie `Set-Cookie` header value. /// /// Deliberately host-only (no `Domain` attribute): a `Domain` scoped to @@ -109,6 +135,7 @@ pub fn handle_trace_mode( mod tests { use super::*; use crate::test_support::tests::create_test_settings; + use http::{Request, header}; fn trace_enabled_settings() -> Settings { let mut settings = create_test_settings(); @@ -215,4 +242,34 @@ mod tests { "disabled trace route should not set a cookie" ); } + + #[test] + fn trace_cookie_boot_resolver_accepts_only_one_exact_server_cookie() { + for (cookie, expected) in [ + (None, false), + (Some("ts-trace=1"), true), + (Some("other=value; ts-trace=1"), true), + (Some("ts-trace=0"), false), + (Some("ts-trace=true"), false), + (Some("ts-trace =1"), false), + (Some("ts-trace= 1"), false), + (Some("ts-trace=1; ts-trace=1"), false), + ] { + let mut builder = Request::builder() + .method("GET") + .uri("https://publisher.example/article"); + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + let request = builder + .body(EdgeBody::empty()) + .expect("should build trace-cookie request"); + + assert_eq!( + render_trace_overlay_active(&request), + expected, + "unexpected boot resolution for {cookie:?}" + ); + } + } } diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts index 79fea5f0a..c40af3e56 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -1,127 +1,18 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; const bootstrapPath = resolve( process.cwd(), '../../trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js' ); const bootstrapSource = readFileSync(bootstrapPath, 'utf8'); -const storageKey = 'tsjs:gptDiagnostics:active'; - -type BootstrapWindow = Window & { - __tsjs_gpt_diagnostics_active?: boolean; -}; - -function runBootstrap(): void { - window.eval(bootstrapSource); -} - -function setUrl(url: string): void { - window.history.replaceState({ fixture: true }, '', url); -} - -function activeFlag(): boolean | undefined { - return (window as BootstrapWindow).__tsjs_gpt_diagnostics_active; -} - -describe('GPT diagnostics activation bootstrap', () => { - beforeEach(() => { - vi.restoreAllMocks(); - window.sessionStorage.clear(); - delete (window as BootstrapWindow).__tsjs_gpt_diagnostics_active; - setUrl('/article?existing=1#section'); - }); - - it.each(['1', 'true'])('activates the current tab for %s', (value) => { - setUrl(`/article?existing=1&ts_console=${value}#section`); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.sessionStorage.getItem(storageKey)).toBe('1'); - expect(window.location.pathname).toBe('/article'); - expect(window.location.search).toBe('?existing=1'); - expect(window.location.hash).toBe('#section'); - expect(window.history.state).toEqual({ fixture: true }); - }); - - it.each(['0', 'false'])('deactivates the current tab for %s', (value) => { - window.sessionStorage.setItem(storageKey, '1'); - setUrl(`/article?ts_console=${value}&existing=1#section`); - - runBootstrap(); - - expect(activeFlag()).toBe(false); - expect(window.sessionStorage.getItem(storageKey)).toBe('0'); - expect(window.location.search).toBe('?existing=1'); - expect(window.location.hash).toBe('#section'); - }); - - it('restores activation from session storage without a directive', () => { - window.sessionStorage.setItem(storageKey, '1'); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe('?existing=1'); - }); - - it('ignores case variants and leaves the directive visible', () => { - window.sessionStorage.setItem(storageKey, '1'); - setUrl('/article?ts_console=True&existing=1#section'); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe('?ts_console=True&existing=1'); - }); - - it('applies a recognized directive to the current document when storage throws', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('storage unavailable'); - }); - setUrl('/article?ts_console=true'); - - expect(() => runBootstrap()).not.toThrow(); - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe(''); - }); - - it('keeps activation when URL cleanup throws', () => { - setUrl('/article?ts_console=true&existing=1#section'); - vi.spyOn(window.history, 'replaceState').mockImplementation(() => { - throw new Error('history unavailable'); - }); - - expect(() => runBootstrap()).not.toThrow(); - expect(activeFlag()).toBe(true); - expect(window.sessionStorage.getItem(storageKey)).toBe('1'); - expect(window.location.search).toBe('?ts_console=true&existing=1'); - }); - - it('removes every activation parameter after recognizing the first value', () => { - setUrl('/article?ts_console=true&existing=1&ts_console=false#section'); - - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(window.location.search).toBe('?existing=1'); - }); - - it('cleans a recognized directive only once across repeated execution', () => { - const nativeReplaceState = window.history.replaceState.bind(window.history); - const replaceState = vi - .spyOn(window.history, 'replaceState') - .mockImplementation((data, unused, url) => nativeReplaceState(data, unused, url)); - setUrl('/article?ts_console=true&existing=1#section'); - replaceState.mockClear(); - - runBootstrap(); - runBootstrap(); - - expect(activeFlag()).toBe(true); - expect(replaceState).toHaveBeenCalledTimes(1); +describe('GPT diagnostics activation ownership', () => { + it('leaves no browser-owned query, storage, history, or activation-flag bootstrap', () => { + expect(bootstrapSource).not.toMatch(/ts_console/); + expect(bootstrapSource).not.toMatch(/sessionStorage|localStorage/); + expect(bootstrapSource).not.toMatch(/replaceState/); + expect(bootstrapSource).not.toMatch(/__tsjs_gpt_diagnostics_active/); }); }); From f91f9d8405284a3b4993a49a6cb7a0d9220c225d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:37:07 -0700 Subject: [PATCH 371/494] Complete runtime render trace diagnostics --- .../trusted-server-js/lib/src/core/trace.ts | 297 +++++++++++++++++- .../lib/src/services/render.ts | 36 ++- .../lib/test/core/trace_runtime.test.ts | 185 ++++++++++- .../lib/test/services/render.test.ts | 25 ++ 4 files changed, 537 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 456902f0e..20a99a20f 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -101,7 +101,7 @@ type PanelStatus = 'ok' | 'hidden' | 'gam-only' | 'empty'; function panelStatus(record: RenderRecord): PanelStatus { if (!record.rendered || record.gamEmpty === true) return 'empty'; - if (record.visible === false) return 'hidden'; + if (record.visible !== true) return 'hidden'; // `ok` requires a *confirmed* TS placement. Anything else — TS applied // targeting only (injected false, creative is GAM's and cross-origin // unreadable), or a path that never reported placement (undefined) — must not @@ -568,9 +568,13 @@ export interface RenderTraceRuntimeScheduler { } export interface RenderTraceRuntimeOptions { + readonly document?: Document | undefined; + readonly exportRecord?: (record: Readonly) => void; readonly now?: () => number; readonly onOverflow?: (droppedNotifications: number) => void; + readonly onPresentationError?: (error: unknown) => void; readonly onSubscriberError?: (error: unknown) => void; + readonly overlayEnabled?: boolean; readonly schedule?: (callback: () => void) => () => void; readonly scheduler?: RenderTraceRuntimeScheduler; } @@ -642,6 +646,287 @@ function scheduleRenderTraceTask(callback: () => void): () => void { return (): void => globalThis.clearTimeout(handle); } +const RUNTIME_TRACE_ATTRIBUTES = [ + 'data-ts-slot-id', + 'data-ts-render-path', + 'data-ts-rendered', + 'data-ts-auction-id', + 'data-ts-bidder', + 'data-ts-ad-id', + 'data-ts-bid-id', + 'data-ts-creative-id', + 'data-ts-adm-hash', + 'data-ts-served-from', + 'data-ts-gam-empty', + 'data-ts-injected', + 'data-ts-visible', +] as const; + +interface PresentedTraceSlot { + readonly element: HTMLElement; + readonly priorInlinePosition?: string; +} + +interface RenderTracePresentation { + readonly present: (record: Readonly) => void; + readonly prune: (slotId: string) => void; + readonly dispose: () => void; +} + +function createRenderTracePresentation( + options: RenderTraceRuntimeOptions, + history: () => readonly Readonly[] +): RenderTracePresentation { + const targetDocument = + options.document ?? (typeof document === 'undefined' ? undefined : document); + const overlayEnabled = options.overlayEnabled === true; + const presented = new Map(); + const panelRecords = new Map>(); + const panelRows = new Map(); + let panel: HTMLElement | undefined; + let panelHeading: HTMLElement | undefined; + let panelRowsHost: HTMLElement | undefined; + + const report = (error: unknown): void => { + try { + options.onPresentationError?.(error); + } catch { + // Presentation reporting is diagnostics-only. + } + }; + + const removeBadge = (element: HTMLElement): void => { + for (const badge of element.querySelectorAll(`:scope > .${TRACE_BADGE_CLASS}`)) badge.remove(); + }; + + const clearElement = (presentedSlot: PresentedTraceSlot): void => { + const { element, priorInlinePosition } = presentedSlot; + for (const attribute of RUNTIME_TRACE_ATTRIBUTES) element.removeAttribute(attribute); + removeBadge(element); + if (priorInlinePosition !== undefined && element.style.position === 'relative') { + element.style.position = priorInlinePosition; + } + }; + + const createBadge = ( + element: HTMLElement, + record: Readonly + ): PresentedTraceSlot => { + let priorInlinePosition: string | undefined; + try { + const position = targetDocument?.defaultView?.getComputedStyle(element).position; + if (position === 'static' || position === '') { + priorInlinePosition = element.style.position; + element.style.position = 'relative'; + } + } catch { + // A badge remains noninteractive even if its containing block is publisher-owned. + } + const status = panelStatus(record as RenderRecord); + const style = STATUS_STYLE[status]; + const badge = targetDocument?.createElement('div'); + if (!badge) { + return { + element, + ...(priorInlinePosition === undefined ? {} : { priorInlinePosition }), + }; + } + badge.className = TRACE_BADGE_CLASS; + badge.textContent = + `TS ${style.mark} #${record.seq}` + + `${record.bidder ? ` · ${record.bidder}` : ''}` + + `${style.label === 'ok' ? '' : ` · ${style.label}`}`; + badge.style.setProperty('position', 'absolute'); + badge.style.setProperty('top', '4px'); + badge.style.setProperty('left', '4px'); + badge.style.setProperty('z-index', '2147483646'); + badge.style.setProperty('pointer-events', 'none'); + badge.style.setProperty('font', '10px/1.5 ui-monospace, Menlo, Consolas, monospace'); + badge.style.setProperty('padding', '1px 5px'); + badge.style.setProperty('color', '#fff'); + badge.style.setProperty('background', style.color); + badge.style.setProperty('border-radius', '3px'); + element.appendChild(badge); + return { element, ...(priorInlinePosition === undefined ? {} : { priorInlinePosition }) }; + }; + + const exportRow = (record: Readonly): void => { + const copied = copyRenderTraceRecord(record); + try { + if (options.exportRecord) { + options.exportRecord(copied); + return; + } + const clipboard = targetDocument?.defaultView?.navigator.clipboard; + const write = clipboard?.writeText; + if (typeof write !== 'function') return; + const pending = Reflect.apply(write, clipboard, [JSON.stringify(copied, null, 2)]) as + Promise | undefined; + void pending?.catch(report); + } catch (error) { + report(error); + } + }; + + const renderPanel = (record?: Readonly): void => { + if (!overlayEnabled || !targetDocument?.body) return; + if (!panel) { + const collision = targetDocument.getElementById(TRACE_PANEL_ID); + if (collision) return; + panel = targetDocument.createElement('div'); + panel.id = TRACE_PANEL_ID; + panel.setAttribute('data-ts-render-trace-owner', '1'); + panel.style.setProperty('position', 'fixed'); + panel.style.setProperty('bottom', '12px'); + panel.style.setProperty('right', '12px'); + panel.style.setProperty('z-index', '2147483647'); + panel.style.setProperty('max-width', '360px'); + panel.style.setProperty('max-height', '45vh'); + panel.style.setProperty('overflow', 'auto'); + panel.style.setProperty('background', 'rgba(17,17,17,0.94)'); + panel.style.setProperty('color', '#eee'); + panel.style.setProperty('font', '11px/1.5 ui-monospace, Menlo, Consolas, monospace'); + panel.style.setProperty('border', '1px solid #333'); + panel.style.setProperty('border-radius', '6px'); + panel.style.setProperty('box-shadow', '0 4px 16px rgba(0,0,0,0.4)'); + panelHeading = targetDocument.createElement('div'); + panelHeading.style.setProperty('padding', '6px 10px'); + panelHeading.style.setProperty('font-weight', '700'); + panelRowsHost = targetDocument.createElement('div'); + panel.append(panelHeading, panelRowsHost); + targetDocument.body.appendChild(panel); + } + const retained = history(); + panelHeading!.textContent = `TS Render Trace · ${retained.length} renders`; + const retainedSequences = new Set(retained.map(({ seq }) => seq)); + for (const [sequence, row] of panelRows) { + if (retainedSequences.has(sequence)) continue; + row.remove(); + panelRows.delete(sequence); + panelRecords.delete(sequence); + } + if (record && retainedSequences.has(record.seq)) { + panelRecords.set(record.seq, record); + let row = panelRows.get(record.seq); + if (!row) { + row = targetDocument.createElement('button'); + row.type = 'button'; + row.setAttribute('data-ts-trace-seq', String(record.seq)); + row.style.setProperty('display', 'block'); + row.style.setProperty('width', '100%'); + row.style.setProperty('padding', '6px 10px'); + row.style.setProperty('border', '0'); + row.style.setProperty('border-top', '1px solid #2a2a2a'); + row.style.setProperty('background', 'transparent'); + row.style.setProperty('font', 'inherit'); + row.style.setProperty('text-align', 'left'); + row.style.setProperty('cursor', 'pointer'); + row.addEventListener('click', () => { + const exported = panelRecords.get(record.seq); + if (exported) exportRow(exported); + }); + panelRows.set(record.seq, row); + panelRowsHost!.prepend(row); + } + const status = panelStatus(record as RenderRecord); + const style = STATUS_STYLE[status]; + row.textContent = `#${record.seq} ${style.mark} ${record.slotId} · ${style.label} · ${record.path}`; + row.style.setProperty('border-left', `3px solid ${style.color}`); + row.style.setProperty('color', style.color); + } + }; + + const present = (record: Readonly): void => { + try { + const prior = presented.get(record.slotId); + const elementId = record.elementId ?? record.slotId; + const candidate = targetDocument?.getElementById(elementId); + const element = candidate && candidate instanceof HTMLElement ? candidate : undefined; + if (prior && prior.element !== element) { + clearElement(prior); + presented.delete(record.slotId); + } + if (element) { + const retainedPosition = prior?.element === element ? prior.priorInlinePosition : undefined; + removeBadge(element); + const values: Readonly< + Record<(typeof RUNTIME_TRACE_ATTRIBUTES)[number], string | undefined> + > = { + 'data-ts-slot-id': record.slotId, + 'data-ts-render-path': record.path, + 'data-ts-rendered': String(record.rendered), + 'data-ts-auction-id': record.auctionId, + 'data-ts-bidder': record.bidder, + 'data-ts-ad-id': record.adId, + 'data-ts-bid-id': record.bidId, + 'data-ts-creative-id': record.creativeId, + 'data-ts-adm-hash': record.admHash, + 'data-ts-served-from': record.servedFrom, + 'data-ts-gam-empty': record.gamEmpty === undefined ? undefined : String(record.gamEmpty), + 'data-ts-injected': record.injected === undefined ? undefined : String(record.injected), + 'data-ts-visible': record.visible === undefined ? undefined : String(record.visible), + }; + for (const attribute of RUNTIME_TRACE_ATTRIBUTES) { + const value = values[attribute]; + if (value === undefined || value === '') element.removeAttribute(attribute); + else element.setAttribute(attribute, value); + } + const status = panelStatus(record as RenderRecord); + if ( + overlayEnabled && + element.tagName !== 'IFRAME' && + (status === 'ok' || status === 'gam-only') + ) { + const next = createBadge(element, record); + presented.set(record.slotId, { + element, + ...(retainedPosition === undefined + ? next.priorInlinePosition === undefined + ? {} + : { priorInlinePosition: next.priorInlinePosition } + : { priorInlinePosition: retainedPosition }), + }); + } else { + if (retainedPosition !== undefined && element.style.position === 'relative') { + element.style.position = retainedPosition; + } + presented.set(record.slotId, { element }); + } + } + renderPanel(record); + } catch (error) { + report(error); + } + }; + + const prune = (slotId: string): void => { + try { + const existing = presented.get(slotId); + if (existing) clearElement(existing); + presented.delete(slotId); + renderPanel(); + } catch (error) { + report(error); + } + }; + + const dispose = (): void => { + for (const slotId of [...presented.keys()]) prune(slotId); + try { + panel?.remove(); + } catch (error) { + report(error); + } + panel = undefined; + panelHeading = undefined; + panelRowsHost = undefined; + panelRecords.clear(); + panelRows.clear(); + }; + + return Object.freeze({ present, prune, dispose }); +} + /** Create one document-runtime render trace without exposing its mutation authority. */ export function createRenderTraceDiagnostics( options: RenderTraceRuntimeOptions = {} @@ -658,6 +943,7 @@ export function createRenderTraceDiagnostics( let reportedDroppedNotifications = 0; let cancelScheduled: (() => void) | undefined; let disposed = false; + const presentation = createRenderTracePresentation(options, () => history); const schedule = (callback: () => void): (() => void) => { if (options.schedule) return options.schedule(callback); @@ -760,7 +1046,10 @@ export function createRenderTraceDiagnostics( if (disposed) return committed; if (!previous && current.size >= MAX_RENDER_TRACE_SLOTS) { const oldestSlot = current.keys().next().value as string | undefined; - if (oldestSlot !== undefined) current.delete(oldestSlot); + if (oldestSlot !== undefined) { + current.delete(oldestSlot); + presentation.prune(oldestSlot); + } } current.set(committed.slotId, committed); recordsBySequence.set(committed.seq, committed); @@ -771,6 +1060,7 @@ export function createRenderTraceDiagnostics( } if (previous && !retained(previous)) recordsBySequence.delete(previous.seq); enqueue(committed); + presentation.present(committed); return committed; }; @@ -811,6 +1101,7 @@ export function createRenderTraceDiagnostics( const historyIndex = history.findIndex(({ seq }) => seq === targetSequence); if (historyIndex >= 0) history[historyIndex] = committed; enqueue(committed); + presentation.present(committed); return committed; }; @@ -822,6 +1113,7 @@ export function createRenderTraceDiagnostics( } current.delete(slotId); if (!retained(existing)) recordsBySequence.delete(existing.seq); + presentation.prune(slotId); return true; }; @@ -874,6 +1166,7 @@ export function createRenderTraceDiagnostics( current.clear(); history.length = 0; recordsBySequence.clear(); + presentation.dispose(); }; return Object.freeze({ api, diagnostics: api, record, enrich, prune, dispose }); diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 417bff92e..5d9c059c8 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -642,8 +642,12 @@ export interface RenderAttemptOptions { } export interface RenderAttemptDiagnosticsObservation extends Readonly> { + readonly adId?: string; readonly kind: 'render_attempt'; readonly attemptId: string; + readonly bidId?: string; + readonly creativeId?: string; + readonly injected: boolean; readonly slotId: string; readonly path: 'auction' | 'ssat'; readonly rendered: boolean; @@ -1558,19 +1562,45 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp } } if (publishDiagnostics) { + const accepted = terminal.outcome === 'accepted'; const servedFrom = - terminal.outcome === 'accepted' && terminalRenderSource?.type === 'cache' + accepted && terminalRenderSource?.type === 'cache' ? ('pbs-cache' as const) - : terminal.outcome === 'accepted' + : accepted ? ('inline' as const) : undefined; + let sourceIdentity: Readonly<{ adId?: string; bidId?: string; creativeId?: string }> = + Object.freeze({}); + if (accepted && terminalRenderSource) { + try { + const readString = (name: string): string | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(terminalRenderSource, name); + return descriptor && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined; + }; + const bidId = terminalRenderSource.type === 'aps' ? readString('bidId') : undefined; + const creativeId = + terminalRenderSource.type === 'aps' ? readString('creativeId') : undefined; + const adId = terminalRenderSource.type === 'cache' ? readString('cacheId') : undefined; + sourceIdentity = frozen({ + ...(adId === undefined ? {} : { adId }), + ...(bidId === undefined ? {} : { bidId }), + ...(creativeId === undefined ? {} : { creativeId }), + }); + } catch { + // Optional trace identity cannot affect the committed terminal state. + } + } const observation = frozen({ kind: 'render_attempt', attemptId: id, slotId: slot, path: history.includes('waiting_for_gam_and_claim') ? 'ssat' : 'auction', - rendered: terminal.outcome === 'accepted', + rendered: accepted, + injected: accepted, ...(servedFrom === undefined ? {} : { servedFrom }), + ...sourceIdentity, state: terminal.outcome, outcome: terminal, }); diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index fef447bc5..0f1f9da8c 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { createRenderTrace, DiagnosticsSubscriberLimitError } from '../../src/core/trace'; +import { + createRenderTrace, + DiagnosticsSubscriberLimitError, + TRACE_BADGE_CLASS, + TRACE_PANEL_ID, +} from '../../src/core/trace'; function harness() { const tasks: Array<() => void> = []; @@ -198,4 +203,182 @@ describe('render trace diagnostics runtime', () => { expect(owner.diagnostics.subscribe(() => undefined)).toBeTypeOf('function'); expect(() => owner.diagnostics.subscribe(null as never)).toThrow(TypeError); }); + + it('uses the server-resolved boot bit instead of reading the trace cookie', () => { + document.cookie = 'ts-trace=1; Path=/'; + const disarmedSlot = document.createElement('div'); + disarmedSlot.id = 'disarmed-slot'; + document.body.append(disarmedSlot); + const disarmed = createRenderTrace({ document, overlayEnabled: false }); + + disarmed.record({ + slotId: 'disarmed-slot', + elementId: 'disarmed-slot', + path: 'auction', + rendered: true, + injected: true, + visible: true, + }); + + expect(disarmedSlot.getAttribute('data-ts-rendered')).toBe('true'); + expect(disarmedSlot.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + disarmed.dispose(); + disarmedSlot.remove(); + document.cookie = 'ts-trace=; Max-Age=0; Path=/'; + + const armedSlot = document.createElement('div'); + armedSlot.id = 'armed-slot'; + document.body.append(armedSlot); + const armed = createRenderTrace({ document, overlayEnabled: true }); + armed.record({ + slotId: 'armed-slot', + elementId: 'armed-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + + const badge = armedSlot.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement | null; + expect(badge).not.toBeNull(); + expect(badge?.style.pointerEvents).toBe('none'); + expect(document.getElementById(TRACE_PANEL_ID)).not.toBeNull(); + armed.dispose(); + armedSlot.remove(); + }); + + it('removes stale stamps and badges on a later physical impression', () => { + const slot = document.createElement('div'); + slot.id = 'restamped-slot'; + document.body.append(slot); + const owner = createRenderTrace({ document, overlayEnabled: true }); + owner.record({ + slotId: 'restamped-slot', + elementId: 'restamped-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + bidder: 'first-bidder', + admHash: 'first-hash', + }); + expect(slot.getAttribute('data-ts-bidder')).toBe('first-bidder'); + expect(slot.querySelector(`.${TRACE_BADGE_CLASS}`)).not.toBeNull(); + + owner.record({ + slotId: 'restamped-slot', + elementId: 'restamped-slot', + path: 'gam-refresh', + rendered: true, + injected: true, + visible: false, + }); + + expect(slot.hasAttribute('data-ts-bidder')).toBe(false); + expect(slot.hasAttribute('data-ts-adm-hash')).toBe(false); + expect(slot.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + owner.dispose(); + expect(slot.hasAttribute('data-ts-slot-id')).toBe(false); + slot.remove(); + }); + + it('stamps iframe slots without placing UI inside the creative frame', () => { + const iframe = document.createElement('iframe'); + iframe.id = 'iframe-slot'; + document.body.append(iframe); + const owner = createRenderTrace({ document, overlayEnabled: true }); + + owner.record({ + slotId: 'iframe-slot', + elementId: 'iframe-slot', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + }); + + expect(iframe.getAttribute('data-ts-rendered')).toBe('true'); + expect(iframe.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + owner.dispose(); + iframe.remove(); + }); + + it('does not claim an ok badge before visibility is positively observed', () => { + const slot = document.createElement('div'); + slot.id = 'unobserved-slot'; + document.body.append(slot); + const owner = createRenderTrace({ document, overlayEnabled: true }); + + owner.record({ + slotId: 'unobserved-slot', + elementId: 'unobserved-slot', + path: 'auction', + rendered: true, + injected: true, + }); + + expect(slot.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + expect(document.getElementById(TRACE_PANEL_ID)?.textContent).toContain('hidden'); + owner.dispose(); + slot.remove(); + }); + + it('does not claim or remove a publisher-owned overlay id collision', () => { + const publisherPanel = document.createElement('div'); + publisherPanel.id = TRACE_PANEL_ID; + publisherPanel.textContent = 'publisher'; + document.body.append(publisherPanel); + const owner = createRenderTrace({ document, overlayEnabled: true }); + + owner.record({ slotId: 'slot-a', path: 'auction', rendered: true }); + + expect(document.getElementById(TRACE_PANEL_ID)).toBe(publisherPanel); + expect(publisherPanel.textContent).toBe('publisher'); + owner.dispose(); + expect(document.getElementById(TRACE_PANEL_ID)).toBe(publisherPanel); + publisherPanel.remove(); + }); + + it('keeps a bounded newest-first overlay and exports frozen row data', () => { + const exportRecord = vi.fn(); + const owner = createRenderTrace({ document, overlayEnabled: true, exportRecord }); + for (let index = 1; index <= 201; index += 1) { + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }); + } + + const panel = document.getElementById(TRACE_PANEL_ID)!; + const rows = [...panel.querySelectorAll('[data-ts-trace-seq]')]; + expect(rows).toHaveLength(200); + expect(rows[0]?.dataset['tsTraceSeq']).toBe('201'); + expect(rows[rows.length - 1]?.dataset['tsTraceSeq']).toBe('2'); + rows[0]?.click(); + expect(exportRecord).toHaveBeenCalledOnce(); + expect(exportRecord).toHaveBeenCalledWith( + expect.objectContaining({ slotId: 'slot-201', seq: 201 }) + ); + expect(Object.isFrozen(exportRecord.mock.calls[0]?.[0])).toBe(true); + owner.dispose(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('isolates presentation failures after committing diagnostics state', () => { + const onPresentationError = vi.fn(); + const hostileDocument = { + getElementById: () => { + throw new Error('hostile document'); + }, + } as unknown as Document; + const owner = createRenderTrace({ + document: hostileDocument, + overlayEnabled: true, + onPresentationError, + }); + + expect(() => owner.record({ slotId: 'slot-a', path: 'auction', rendered: true })).not.toThrow(); + expect(owner.diagnostics.current()['slot-a']).toEqual( + expect.objectContaining({ slotId: 'slot-a', rendered: true }) + ); + expect(onPresentationError).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index 028d0b47c..b08aee273 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -4577,12 +4577,37 @@ describe('RenderAttempt diagnostics producer', () => { slotId: renderAttempt.slot, path: 'auction', rendered: true, + injected: true, servedFrom: 'inline', state: 'accepted', outcome: { outcome: 'accepted' }, }); }); + it('publishes source-owned APS trace identity without exposing the creative payload', () => { + const publishDiagnostics = vi.fn(); + const renderAttempt = attempt(owner(), { publishDiagnostics }); + expect(renderAttempt.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); + expect(renderAttempt.beginDirect()).toBe(true); + const committed = artifact(renderAttempt); + expect(renderAttempt.beginApsDocument(committed)).toBe(true); + expect(renderAttempt.apsDocumentAccepted()).toBe(true); + + expect(renderAttempt.accept()).toBe(true); + + expect(publishDiagnostics).toHaveBeenCalledWith( + expect.objectContaining({ + bidId: DIRECT_APS_SOURCE.bidId, + creativeId: DIRECT_APS_SOURCE.creativeId, + injected: true, + rendered: true, + }) + ); + const observation = publishDiagnostics.mock.calls[0]?.[0] as Record; + expect(observation).not.toHaveProperty('aaxResponse'); + expect(observation).not.toHaveProperty('creativeUrl'); + }); + it('publishes terminal failure after the lifecycle state commit and never republishes', () => { const attemptReference: { current?: RenderAttempt } = {}; const observedStates: RenderAttemptState[] = []; From 328f5cd7051348595d51ae8a5df4c036b25fc757 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:38:05 -0700 Subject: [PATCH 372/494] Prepare remaining integration lifecycles --- .../lib/src/integrations/datadome/module.ts | 53 ++ .../lib/src/integrations/didomi/module.ts | 145 +++++ .../integrations/google_tag_manager/module.ts | 78 +++ .../lib/src/integrations/lockr/module.ts | 132 +++++ .../src/integrations/osano/consent_mirror.ts | 539 ++++++++++++++++++ .../lib/src/integrations/osano/index.ts | 518 +---------------- .../lib/src/integrations/osano/module.ts | 49 ++ .../lib/src/integrations/permutive/module.ts | 210 +++++++ .../sourcepoint/consent_mirror.ts | 299 ++++++++++ .../lib/src/integrations/sourcepoint/index.ts | 302 +--------- .../src/integrations/sourcepoint/module.ts | 106 ++++ .../lib/src/integrations/testlight/module.ts | 224 ++++++++ .../lib/src/kernel/lifecycle_module.ts | 132 +++++ .../test/integrations/datadome/module.test.ts | 120 ++++ .../test/integrations/didomi/module.test.ts | 81 +++ .../google_tag_manager/module.test.ts | 100 ++++ .../integrations/lifecycle_modules.test.ts | 117 ++++ .../test/integrations/lockr/module.test.ts | 87 +++ .../lib/test/integrations/osano/index.test.ts | 35 +- .../test/integrations/osano/module.test.ts | 37 ++ .../integrations/permutive/module.test.ts | 123 ++++ .../integrations/sourcepoint/module.test.ts | 68 +++ .../integrations/testlight/module.test.ts | 104 ++++ .../lib/test/kernel/lifecycle_module.test.ts | 94 +++ 24 files changed, 2950 insertions(+), 803 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/datadome/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/didomi/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/lockr/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/osano/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/permutive/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/testlight/module.ts create mode 100644 crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/osano/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts create mode 100644 crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/datadome/module.ts b/crates/trusted-server-js/lib/src/integrations/datadome/module.ts new file mode 100644 index 000000000..6351d985c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/datadome/module.ts @@ -0,0 +1,53 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { installDataDomeGuard, resetGuardState } from './script_guard'; + +export const DATADOME_INTEGRATION_ID = 'datadome' as const; + +export interface DataDomeRuntimeDependencies { + readonly installGuard: () => void; + readonly resetGuard: () => void; + readonly started: () => void; +} + +/** Own the reversible DataDome script/preload guard for one runtime. */ +export function createDataDomeRuntime( + dependencies: DataDomeRuntimeDependencies = { + installGuard: installDataDomeGuard, + resetGuard: resetGuardState, + started: () => log.info('DataDome integration initialized'), + } +): IntegrationLifecycleRuntime { + return Object.freeze({ + activate: (_config: unknown) => { + try { + dependencies.installGuard(); + } catch (error) { + try { + dependencies.resetGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + let active = true; + return (): void => { + if (!active) return; + active = false; + dependencies.resetGuard(); + }; + }, + start: (_config: unknown) => dependencies.started(), + }); +} + +export function createDataDomeIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(DATADOME_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/didomi/module.ts b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts new file mode 100644 index 000000000..871bd9c51 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts @@ -0,0 +1,145 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +export const DIDOMI_INTEGRATION_ID = 'didomi' as const; + +interface DidomiConfig { + sdkPath?: string; + [key: string]: unknown; +} + +export interface DidomiRuntimeTarget { + didomiConfig?: DidomiConfig; + readonly location: { readonly href?: string; readonly origin?: string }; +} + +export interface DidomiRuntimeDependencies { + readonly started: () => void; + readonly target: DidomiRuntimeTarget; +} + +function didomiBootConfig(candidate: unknown): candidate is Readonly<{ proxyPath: string }> { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Reflect.ownKeys(candidate).length !== 1 + ) { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(candidate, 'proxyPath'); + return Boolean( + descriptor?.enumerable && + 'value' in descriptor && + typeof descriptor.value === 'string' && + descriptor.value.startsWith('/') && + !descriptor.value.startsWith('//') && + !descriptor.value.startsWith('/\\') && + descriptor.value.length <= 2_048 && + !descriptor.value.includes('?') && + !descriptor.value.includes('#') + ); + } catch { + return false; + } +} + +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + return Boolean( + left && + right && + 'value' in left && + 'value' in right && + left.value === right.value && + left.configurable === right.configurable && + left.enumerable === right.enumerable && + left.writable === right.writable + ); +} + +/** Own only Didomi's proxied `sdkPath`, preserving all publisher configuration. */ +export function createDidomiRuntime( + dependencies: DidomiRuntimeDependencies = { + started: () => log.info('Didomi integration initialized'), + target: window as DidomiRuntimeTarget, + } +): IntegrationLifecycleRuntime { + return Object.freeze({ + activate: (candidate: unknown): (() => void) => { + if (!didomiBootConfig(candidate)) throw new TypeError('Didomi config is invalid'); + const base = dependencies.target.location.origin ?? dependencies.target.location.href; + if (!base) throw new TypeError('Didomi publisher origin is unavailable'); + const parsed = new URL(candidate.proxyPath, base); + if (parsed.origin !== new URL(base).origin) { + throw new TypeError('Didomi proxy path must remain on the publisher origin'); + } + const installedPath = `${parsed.origin}${parsed.pathname}`; + const previousTargetDescriptor = Object.getOwnPropertyDescriptor( + dependencies.target, + 'didomiConfig' + ); + let config = dependencies.target.didomiConfig; + const created = config === undefined; + if (created) { + config = {}; + if (!Reflect.set(dependencies.target, 'didomiConfig', config)) { + throw new TypeError('Didomi publisher config is not writable'); + } + } + if (typeof config !== 'object' || config === null) { + throw new TypeError('Didomi publisher config is invalid'); + } + const previousSdkDescriptor = Object.getOwnPropertyDescriptor(config, 'sdkPath'); + if (previousSdkDescriptor && !('value' in previousSdkDescriptor)) { + throw new TypeError('Didomi sdkPath accessor is unsupported'); + } + if (!Reflect.set(config, 'sdkPath', installedPath)) { + throw new TypeError('Didomi sdkPath is not writable'); + } + const installedSdkDescriptor = Object.getOwnPropertyDescriptor(config, 'sdkPath'); + let active = true; + return (): void => { + if (!active) return; + active = false; + try { + if (dependencies.target.didomiConfig !== config) return; + const current = Object.getOwnPropertyDescriptor(config, 'sdkPath'); + if (!sameDescriptor(current, installedSdkDescriptor)) return; + if (previousSdkDescriptor) + Object.defineProperty(config, 'sdkPath', previousSdkDescriptor); + else Reflect.deleteProperty(config, 'sdkPath'); + if ( + created && + Reflect.ownKeys(config).length === 0 && + Object.getOwnPropertyDescriptor(dependencies.target, 'didomiConfig')?.value === config + ) { + if (previousTargetDescriptor) { + Object.defineProperty(dependencies.target, 'didomiConfig', previousTargetDescriptor); + } else { + Reflect.deleteProperty(dependencies.target, 'didomiConfig'); + } + } + } catch { + // Publisher replacement wins over cleanup. + } + }; + }, + start: (_config: unknown): void => dependencies.started(), + }); +} + +export function createDidomiIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(DIDOMI_INTEGRATION_ID, release, { + validateConfig: didomiBootConfig, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts new file mode 100644 index 000000000..21f28cc6c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/module.ts @@ -0,0 +1,78 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { + installGtmBeaconGuard, + installGtmGuard, + resetBeaconGuardState, + resetGuardState, +} from './script_guard'; + +export const GOOGLE_TAG_MANAGER_INTEGRATION_ID = 'google_tag_manager' as const; + +export interface GoogleTagManagerRuntimeDependencies { + readonly installBeaconGuard: () => void; + readonly installScriptGuard: () => void; + readonly resetBeaconGuard: () => void; + readonly resetScriptGuard: () => void; + readonly started: () => void; +} + +/** Own the reversible GTM script/preload and GA network guards for one runtime. */ +export function createGoogleTagManagerRuntime( + dependencies: GoogleTagManagerRuntimeDependencies = { + installBeaconGuard: installGtmBeaconGuard, + installScriptGuard: installGtmGuard, + resetBeaconGuard: resetBeaconGuardState, + resetScriptGuard: resetGuardState, + started: () => log.info('Google Tag Manager integration initialized'), + } +): IntegrationLifecycleRuntime { + return Object.freeze({ + activate: (_config: unknown) => { + let beaconAttempted = false; + try { + dependencies.installScriptGuard(); + beaconAttempted = true; + dependencies.installBeaconGuard(); + } catch (error) { + if (beaconAttempted) { + try { + dependencies.resetBeaconGuard(); + } catch { + // Continue through independent script-guard rollback. + } + } + try { + dependencies.resetScriptGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + let active = true; + return (): void => { + if (!active) return; + active = false; + try { + dependencies.resetBeaconGuard(); + } finally { + dependencies.resetScriptGuard(); + } + }; + }, + start: (_config: unknown) => dependencies.started(), + }); +} + +export function createGoogleTagManagerIntegrationRegistration( + release: string +): IntegrationRegistration { + return createLifecycleIntegrationRegistration(GOOGLE_TAG_MANAGER_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/module.ts b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts new file mode 100644 index 000000000..0b91d31ed --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts @@ -0,0 +1,132 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { installLockrGuard, resetGuardState } from './script_guard'; + +export const LOCKR_INTEGRATION_ID = 'lockr' as const; + +interface LockrSdk { + host: string; +} + +export interface LockrRuntimeDependencies { + readonly clearTimeout: (timer: number) => void; + readonly getSdk: () => LockrSdk | undefined; + readonly installGuard: () => void; + readonly location: { readonly host: string; readonly protocol: string }; + readonly resetGuard: () => void; + readonly setTimeout: (callback: () => void, delay: number) => number; + readonly started: () => void; + readonly timedOut: () => void; +} + +/** Own the Lockr guard, bounded SDK readiness timer, and installed API host. */ +export function createLockrRuntime( + dependencies: LockrRuntimeDependencies = { + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (globalThis as typeof globalThis & { identityLockr?: LockrSdk }).identityLockr, + installGuard: installLockrGuard, + location: window.location, + resetGuard: resetGuardState, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: () => log.info('Lockr integration initialized'), + timedOut: () => log.warn('Lockr SDK not detected after', 2_500, 'ms'), + } +): IntegrationLifecycleRuntime { + let active = false; + let started = false; + let timer: number | undefined; + let installedHost: string | undefined; + let previousHost: string | undefined; + let ownedSdk: LockrSdk | undefined; + + const resetSdk = (): void => { + const sdk = ownedSdk; + const installed = installedHost; + const previous = previousHost; + ownedSdk = undefined; + installedHost = undefined; + previousHost = undefined; + if (!sdk || installed === undefined || previous === undefined) return; + try { + if (sdk.host === installed) sdk.host = previous; + } catch { + // Publisher replacement wins over cleanup. + } + }; + + return Object.freeze({ + activate: (_config: unknown): (() => void) => { + if (active) throw new Error('Lockr runtime is already active'); + try { + dependencies.installGuard(); + } catch (error) { + try { + dependencies.resetGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + active = true; + return (): void => { + if (!active) return; + active = false; + started = false; + if (timer !== undefined) { + dependencies.clearTimeout(timer); + timer = undefined; + } + resetSdk(); + dependencies.resetGuard(); + }; + }, + start: (_config: unknown): void => { + if (!active || started) return; + started = true; + dependencies.started(); + let attempts = 0; + const check = (): void => { + timer = undefined; + if (!active) return; + attempts += 1; + let sdk: LockrSdk | undefined; + try { + sdk = dependencies.getSdk(); + if (sdk && typeof sdk.host === 'string' && sdk.host.length > 0) { + const protocol = dependencies.location.protocol === 'https:' ? 'https' : 'http'; + const nextHost = `${protocol}://${dependencies.location.host}/integrations/lockr/api`; + const originalHost = sdk.host; + sdk.host = nextHost; + ownedSdk = sdk; + previousHost = originalHost; + installedHost = nextHost; + return; + } + } catch { + // Treat an unreadable or unwritable SDK as not ready. + } + if (attempts >= 50) { + dependencies.timedOut(); + return; + } + try { + timer = dependencies.setTimeout(check, 50); + } catch { + dependencies.timedOut(); + } + }; + check(); + }, + }); +} + +export function createLockrIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(LOCKR_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts new file mode 100644 index 000000000..7592bfadc --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts @@ -0,0 +1,539 @@ +import { log } from '../../core/log'; + +const MARKER_COOKIE_NAME = '_ts_consent_src'; +const MARKER_COOKIE_VALUE = 'osano'; +const US_PRIVACY_COOKIE_NAME = 'us_privacy'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const TCF_COOKIE_NAME = 'euconsent-v2'; +const TARGET_COOKIE_NAMES = [ + US_PRIVACY_COOKIE_NAME, + GPP_COOKIE_NAME, + GPP_SID_COOKIE_NAME, + TCF_COOKIE_NAME, +]; +const API_TIMEOUT_MS = 500; +const OSANO_RETRY_DELAY_MS = 250; +const OSANO_MAX_RETRIES = 20; +const MIRROR_DEBOUNCE_MS = 0; + +const OSANO_EVENTS = [ + 'osano-cm-initialized', + 'osano-cm-consent-saved', + 'osano-cm-consent-new', + 'osano-cm-consent-changed', + 'osano-cm-opt-out', + 'osano-cm-storage', +] as const; +const OSANO_CLEAR_READY_EVENTS = new Set([ + 'osano-cm-initialized', + 'osano-cm-consent-saved', + 'osano-cm-consent-new', + 'osano-cm-consent-changed', + 'osano-cm-opt-out', +]); + +interface UspData { + uspString?: string; +} + +interface GppPingData { + signalStatus?: string; + gppString?: string; + applicableSections?: number[]; +} + +interface TcfData { + tcString?: string; + eventStatus?: string; +} + +interface OsanoCm { + addEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; + removeEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; +} + +type UspApi = ( + command: 'getUSPData', + version: 1, + callback: (data?: UspData, success?: boolean) => void +) => void; + +type GppApi = (command: 'ping', callback: (data?: GppPingData, success?: boolean) => void) => void; + +type TcfApi = ( + command: 'getTCData', + version: 2, + callback: (data?: TcfData, success?: boolean) => void +) => void; + +type OsanoWindow = Window & { + Osano?: { + cm?: OsanoCm; + }; + __uspapi?: UspApi; + __gpp?: GppApi; + __tcfapi?: TcfApi; +}; + +interface CookieWrite { + name: string; + value: string; +} + +interface SignalResult { + writes: CookieWrite[]; + clears: string[]; + pending: boolean; +} + +interface MirrorPlan { + writes: CookieWrite[]; + clears: string[]; + pending: boolean; +} + +let initialized = false; +let osanoListenersInstalled = false; +let osanoRetryCount = 0; +let osanoReadyForClears = false; +let osanoRetryTimer: number | undefined; +let mirrorTimer: number | undefined; +let mirrorGeneration = 0; +let osanoEventHandlers: Map void> | undefined; +let osanoListenerOwner: OsanoCm | undefined; +let focusHandler: (() => void) | undefined; +let visibilityHandler: (() => void) | undefined; +const pendingSignalCancels = new Set<() => void>(); + +function getWindow(): OsanoWindow | undefined { + if (typeof window === 'undefined') return undefined; + return window as OsanoWindow; +} + +function readCookie(name: string): string | undefined { + if (typeof document === 'undefined') return undefined; + + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; Path=/; Secure; SameSite=Lax`; +} + +function clearCookie(name: string): void { + document.cookie = `${name}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +function hasAnyTargetCookie(): boolean { + return TARGET_COOKIE_NAMES.some((name) => readCookie(name) !== undefined); +} + +function ownsConsentCookies(): boolean { + return readCookie(MARKER_COOKIE_NAME) === MARKER_COOKIE_VALUE; +} + +function canWriteConsentCookies(): boolean { + const marker = readCookie(MARKER_COOKIE_NAME); + if (marker === MARKER_COOKIE_VALUE) return true; + + if (marker !== undefined) { + log.debug('osano: preserving consent cookies owned by another mirror', { marker }); + return false; + } + + if (hasAnyTargetCookie()) { + log.debug('osano: preserving existing unmarked consent cookies'); + return false; + } + + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === 'number'); +} + +function shouldWriteGppSid( + applicableSections: number[] | undefined +): applicableSections is number[] { + return ( + Array.isArray(applicableSections) && + applicableSections.length > 0 && + !applicableSections.includes(-1) + ); +} + +function isTcfReady(eventStatus: unknown): boolean { + return eventStatus === 'tcloaded' || eventStatus === 'useractioncomplete'; +} + +function signalResult(writes: CookieWrite[] = [], clears: string[] = []): SignalResult { + return { writes, clears, pending: false }; +} + +function pendingResult(): SignalResult { + return { writes: [], clears: [], pending: true }; +} + +function unavailableResult(): SignalResult { + return { writes: [], clears: [], pending: false }; +} + +function emptyAfterOsanoReadyResult(cookieNames: string | string[]): SignalResult { + if (!osanoReadyForClears) { + return pendingResult(); + } + + return signalResult([], Array.isArray(cookieNames) ? cookieNames : [cookieNames]); +} + +function finishOnce(finish: (value: T) => void): (value: T) => void { + let settled = false; + return (value: T): void => { + if (settled) return; + settled = true; + finish(value); + }; +} + +function readUspSignal(win: OsanoWindow): Promise { + if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + let timer: number | undefined; + let cancelPending = (): void => undefined; + const done = finishOnce((result: SignalResult) => { + if (timer !== undefined) window.clearTimeout(timer); + pendingSignalCancels.delete(cancelPending); + resolve(result); + }); + cancelPending = () => done(pendingResult()); + pendingSignalCancels.add(cancelPending); + timer = window.setTimeout(cancelPending, API_TIMEOUT_MS); + + try { + win.__uspapi?.('getUSPData', 1, (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if ('uspString' in data && typeof data.uspString !== 'string') { + done(pendingResult()); + return; + } + + if (typeof data.uspString === 'string' && data.uspString.length > 0) { + done(signalResult([{ name: US_PRIVACY_COOKIE_NAME, value: data.uspString }])); + return; + } + + done(emptyAfterOsanoReadyResult(US_PRIVACY_COOKIE_NAME)); + }); + } catch (error) { + log.debug('osano: __uspapi getUSPData failed', { error }); + done(pendingResult()); + } + }); +} + +function readGppSignal(win: OsanoWindow): Promise { + if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + let timer: number | undefined; + let cancelPending = (): void => undefined; + const done = finishOnce((result: SignalResult) => { + if (timer !== undefined) window.clearTimeout(timer); + pendingSignalCancels.delete(cancelPending); + resolve(result); + }); + cancelPending = () => done(pendingResult()); + pendingSignalCancels.add(cancelPending); + timer = window.setTimeout(cancelPending, API_TIMEOUT_MS); + + try { + win.__gpp?.('ping', (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if (data.signalStatus !== 'ready') { + done(pendingResult()); + return; + } + + if ('gppString' in data && typeof data.gppString !== 'string') { + done(pendingResult()); + return; + } + + if ( + 'applicableSections' in data && + data.applicableSections !== undefined && + !isNumberArray(data.applicableSections) + ) { + done(pendingResult()); + return; + } + + const applicableSections = data.applicableSections as number[] | undefined; + if (typeof data.gppString === 'string' && data.gppString.length > 0) { + const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }]; + const clears: string[] = []; + + if (shouldWriteGppSid(applicableSections)) { + writes.push({ name: GPP_SID_COOKIE_NAME, value: applicableSections.join(',') }); + } else { + clears.push(GPP_SID_COOKIE_NAME); + } + + done(signalResult(writes, clears)); + return; + } + + done(emptyAfterOsanoReadyResult([GPP_COOKIE_NAME, GPP_SID_COOKIE_NAME])); + }); + } catch (error) { + log.debug('osano: __gpp ping failed', { error }); + done(pendingResult()); + } + }); +} + +function readTcfSignal(win: OsanoWindow): Promise { + if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); + + return new Promise((resolve) => { + let timer: number | undefined; + let cancelPending = (): void => undefined; + const done = finishOnce((result: SignalResult) => { + if (timer !== undefined) window.clearTimeout(timer); + pendingSignalCancels.delete(cancelPending); + resolve(result); + }); + cancelPending = () => done(pendingResult()); + pendingSignalCancels.add(cancelPending); + timer = window.setTimeout(cancelPending, API_TIMEOUT_MS); + + try { + win.__tcfapi?.('getTCData', 2, (data, success) => { + if (success === false || !isRecord(data)) { + done(pendingResult()); + return; + } + + if (!isTcfReady(data.eventStatus)) { + done(pendingResult()); + return; + } + + if ('tcString' in data && typeof data.tcString !== 'string') { + done(pendingResult()); + return; + } + + if (typeof data.tcString === 'string' && data.tcString.length > 0) { + done(signalResult([{ name: TCF_COOKIE_NAME, value: data.tcString }])); + return; + } + + done(emptyAfterOsanoReadyResult(TCF_COOKIE_NAME)); + }); + } catch (error) { + log.debug('osano: __tcfapi getTCData failed', { error }); + done(pendingResult()); + } + }); +} + +async function buildMirrorPlan(win: OsanoWindow): Promise { + const results = await Promise.all([readUspSignal(win), readGppSignal(win), readTcfSignal(win)]); + + return { + writes: results.flatMap((result) => result.writes), + clears: results.flatMap((result) => result.clears), + pending: results.some((result) => result.pending), + }; +} + +function applyMirrorPlan(plan: MirrorPlan): boolean { + if (plan.writes.length === 0 && plan.clears.length === 0) { + return false; + } + + if (!canWriteConsentCookies()) { + return false; + } + + const writeNames = new Set(plan.writes.map((write) => write.name)); + for (const name of plan.clears) { + if (!writeNames.has(name)) clearCookie(name); + } + + for (const write of plan.writes) { + writeCookie(write.name, write.value); + } + + if (hasAnyTargetCookie()) { + writeCookie(MARKER_COOKIE_NAME, MARKER_COOKIE_VALUE); + } else if (ownsConsentCookies()) { + clearCookie(MARKER_COOKIE_NAME); + } + + log.info('osano: mirrored consent to standard cookies', { + writes: plan.writes.map((write) => write.name), + clears: plan.clears, + pending: plan.pending, + }); + + return true; +} + +/** + * Mirrors Osano's IAB API consent signals into standard first-party cookies. + * + * Returns `true` when any cookie was written or cleared, `false` otherwise. + */ +export async function mirrorOsanoConsent(): Promise { + if (typeof document === 'undefined') return false; + + const win = getWindow(); + if (!win) return false; + + const generation = (mirrorGeneration += 1); + const plan = await buildMirrorPlan(win); + + if (generation !== mirrorGeneration) { + return false; + } + + return applyMirrorPlan(plan); +} + +function scheduleMirror(): void { + if (mirrorTimer !== undefined || typeof window === 'undefined') return; + + mirrorTimer = window.setTimeout(() => { + mirrorTimer = undefined; + void mirrorOsanoConsent(); + }, MIRROR_DEBOUNCE_MS); +} + +function installOsanoListeners(): boolean { + const cm = getWindow()?.Osano?.cm; + if (!cm) return false; + + if (osanoListenersInstalled) { + scheduleMirror(); + return true; + } + + if ( + typeof cm.addEventListener !== 'function' || + typeof cm.removeEventListener !== 'function' + ) { + return false; + } + + osanoEventHandlers = new Map(); + osanoListenerOwner = cm; + for (const eventName of OSANO_EVENTS) { + const handler = (): void => { + if (OSANO_CLEAR_READY_EVENTS.has(eventName)) { + osanoReadyForClears = true; + } + scheduleMirror(); + }; + osanoEventHandlers.set(eventName, handler); + cm.addEventListener(eventName, handler); + } + osanoListenersInstalled = true; + + scheduleMirror(); + return true; +} + +function scheduleOsanoRetry(): void { + if (osanoRetryTimer !== undefined || osanoRetryCount >= OSANO_MAX_RETRIES) return; + + osanoRetryCount += 1; + osanoRetryTimer = window.setTimeout(() => { + osanoRetryTimer = undefined; + if (!installOsanoListeners()) { + scheduleOsanoRetry(); + } + }, OSANO_RETRY_DELAY_MS); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + scheduleMirror(); + } +} + +/** + * Initializes the Osano consent mirror. + */ +export function initializeOsanoConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + focusHandler = () => scheduleMirror(); + visibilityHandler = () => mirrorOnVisible(); + window.addEventListener('focus', focusHandler); + document.addEventListener('visibilitychange', visibilityHandler); + + scheduleMirror(); + + if (!installOsanoListeners()) { + scheduleOsanoRetry(); + } +} + +/** Dispose every timer/listener owned by the active Osano consent mirror. */ +export function disposeOsanoConsentMirror(): void { + const cm = osanoListenerOwner; + if ( + osanoListenersInstalled && + osanoEventHandlers && + cm && + typeof cm.removeEventListener === 'function' + ) { + for (const [eventName, handler] of osanoEventHandlers) { + try { + cm.removeEventListener(eventName, handler); + } catch { + // One vendor listener failure cannot retain the remaining owners. + } + } + } + + if (focusHandler) window.removeEventListener('focus', focusHandler); + if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); + if (osanoRetryTimer !== undefined) window.clearTimeout(osanoRetryTimer); + if (mirrorTimer !== undefined) window.clearTimeout(mirrorTimer); + mirrorGeneration += 1; + for (const cancel of [...pendingSignalCancels]) cancel(); + + initialized = false; + osanoListenersInstalled = false; + osanoRetryCount = 0; + osanoReadyForClears = false; + osanoRetryTimer = undefined; + mirrorTimer = undefined; + osanoEventHandlers = undefined; + osanoListenerOwner = undefined; + focusHandler = undefined; + visibilityHandler = undefined; +} diff --git a/crates/trusted-server-js/lib/src/integrations/osano/index.ts b/crates/trusted-server-js/lib/src/integrations/osano/index.ts index ab12df202..135b44591 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts @@ -1,514 +1,10 @@ -import { log } from '../../core/log'; +export { + disposeOsanoConsentMirror, + initializeOsanoConsentMirror, + mirrorOsanoConsent, +} from './consent_mirror'; -const MARKER_COOKIE_NAME = '_ts_consent_src'; -const MARKER_COOKIE_VALUE = 'osano'; -const US_PRIVACY_COOKIE_NAME = 'us_privacy'; -const GPP_COOKIE_NAME = '__gpp'; -const GPP_SID_COOKIE_NAME = '__gpp_sid'; -const TCF_COOKIE_NAME = 'euconsent-v2'; -const TARGET_COOKIE_NAMES = [ - US_PRIVACY_COOKIE_NAME, - GPP_COOKIE_NAME, - GPP_SID_COOKIE_NAME, - TCF_COOKIE_NAME, -]; -const API_TIMEOUT_MS = 500; -const OSANO_RETRY_DELAY_MS = 250; -const OSANO_MAX_RETRIES = 20; -const MIRROR_DEBOUNCE_MS = 0; - -const OSANO_EVENTS = [ - 'osano-cm-initialized', - 'osano-cm-consent-saved', - 'osano-cm-consent-new', - 'osano-cm-consent-changed', - 'osano-cm-opt-out', - 'osano-cm-storage', -] as const; -const OSANO_CLEAR_READY_EVENTS = new Set([ - 'osano-cm-initialized', - 'osano-cm-consent-saved', - 'osano-cm-consent-new', - 'osano-cm-consent-changed', - 'osano-cm-opt-out', -]); - -interface UspData { - uspString?: string; -} - -interface GppPingData { - signalStatus?: string; - gppString?: string; - applicableSections?: number[]; -} - -interface TcfData { - tcString?: string; - eventStatus?: string; -} - -interface OsanoCm { - addEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; - removeEventListener?: (eventName: string, callback: (payload?: unknown) => void) => void; -} - -type UspApi = ( - command: 'getUSPData', - version: 1, - callback: (data?: UspData, success?: boolean) => void -) => void; - -type GppApi = (command: 'ping', callback: (data?: GppPingData, success?: boolean) => void) => void; - -type TcfApi = ( - command: 'getTCData', - version: 2, - callback: (data?: TcfData, success?: boolean) => void -) => void; - -type OsanoWindow = Window & { - Osano?: { - cm?: OsanoCm; - }; - __uspapi?: UspApi; - __gpp?: GppApi; - __tcfapi?: TcfApi; -}; - -interface CookieWrite { - name: string; - value: string; -} - -interface SignalResult { - writes: CookieWrite[]; - clears: string[]; - pending: boolean; -} - -interface MirrorPlan { - writes: CookieWrite[]; - clears: string[]; - pending: boolean; -} - -let initialized = false; -let osanoListenersInstalled = false; -let osanoRetryCount = 0; -let osanoReadyForClears = false; -let osanoRetryTimer: number | undefined; -let mirrorTimer: number | undefined; -let mirrorGeneration = 0; -let osanoEventHandlers: Map void> | undefined; -let focusHandler: (() => void) | undefined; -let visibilityHandler: (() => void) | undefined; - -function getWindow(): OsanoWindow | undefined { - if (typeof window === 'undefined') return undefined; - return window as OsanoWindow; -} - -function readCookie(name: string): string | undefined { - if (typeof document === 'undefined') return undefined; - - const prefix = `${name}=`; - const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); - return cookie?.slice(prefix.length); -} - -function writeCookie(name: string, value: string): void { - document.cookie = `${name}=${value}; Path=/; Secure; SameSite=Lax`; -} - -function clearCookie(name: string): void { - document.cookie = `${name}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; -} - -function hasAnyTargetCookie(): boolean { - return TARGET_COOKIE_NAMES.some((name) => readCookie(name) !== undefined); -} - -function ownsConsentCookies(): boolean { - return readCookie(MARKER_COOKIE_NAME) === MARKER_COOKIE_VALUE; -} - -function canWriteConsentCookies(): boolean { - const marker = readCookie(MARKER_COOKIE_NAME); - if (marker === MARKER_COOKIE_VALUE) return true; - - if (marker !== undefined) { - log.debug('osano: preserving consent cookies owned by another mirror', { marker }); - return false; - } - - if (hasAnyTargetCookie()) { - log.debug('osano: preserving existing unmarked consent cookies'); - return false; - } - - return true; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isNumberArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((item) => typeof item === 'number'); -} - -function shouldWriteGppSid( - applicableSections: number[] | undefined -): applicableSections is number[] { - return ( - Array.isArray(applicableSections) && - applicableSections.length > 0 && - !applicableSections.includes(-1) - ); -} - -function isTcfReady(eventStatus: unknown): boolean { - return eventStatus === 'tcloaded' || eventStatus === 'useractioncomplete'; -} - -function signalResult(writes: CookieWrite[] = [], clears: string[] = []): SignalResult { - return { writes, clears, pending: false }; -} - -function pendingResult(): SignalResult { - return { writes: [], clears: [], pending: true }; -} - -function unavailableResult(): SignalResult { - return { writes: [], clears: [], pending: false }; -} - -function emptyAfterOsanoReadyResult(cookieNames: string | string[]): SignalResult { - if (!osanoReadyForClears) { - return pendingResult(); - } - - return signalResult([], Array.isArray(cookieNames) ? cookieNames : [cookieNames]); -} - -function finishOnce(finish: (value: T) => void): (value: T) => void { - let settled = false; - return (value: T): void => { - if (settled) return; - settled = true; - finish(value); - }; -} - -function readUspSignal(win: OsanoWindow): Promise { - if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); - - return new Promise((resolve) => { - const done = finishOnce((result: SignalResult) => { - window.clearTimeout(timer); - resolve(result); - }); - const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); - - try { - win.__uspapi?.('getUSPData', 1, (data, success) => { - if (success === false || !isRecord(data)) { - done(pendingResult()); - return; - } - - if ('uspString' in data && typeof data.uspString !== 'string') { - done(pendingResult()); - return; - } - - if (typeof data.uspString === 'string' && data.uspString.length > 0) { - done(signalResult([{ name: US_PRIVACY_COOKIE_NAME, value: data.uspString }])); - return; - } - - done(emptyAfterOsanoReadyResult(US_PRIVACY_COOKIE_NAME)); - }); - } catch (error) { - log.debug('osano: __uspapi getUSPData failed', { error }); - done(pendingResult()); - } - }); -} - -function readGppSignal(win: OsanoWindow): Promise { - if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); - - return new Promise((resolve) => { - const done = finishOnce((result: SignalResult) => { - window.clearTimeout(timer); - resolve(result); - }); - const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); - - try { - win.__gpp?.('ping', (data, success) => { - if (success === false || !isRecord(data)) { - done(pendingResult()); - return; - } - - if (data.signalStatus !== 'ready') { - done(pendingResult()); - return; - } - - if ('gppString' in data && typeof data.gppString !== 'string') { - done(pendingResult()); - return; - } - - if ( - 'applicableSections' in data && - data.applicableSections !== undefined && - !isNumberArray(data.applicableSections) - ) { - done(pendingResult()); - return; - } - - const applicableSections = data.applicableSections as number[] | undefined; - if (typeof data.gppString === 'string' && data.gppString.length > 0) { - const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }]; - const clears: string[] = []; - - if (shouldWriteGppSid(applicableSections)) { - writes.push({ name: GPP_SID_COOKIE_NAME, value: applicableSections.join(',') }); - } else { - clears.push(GPP_SID_COOKIE_NAME); - } - - done(signalResult(writes, clears)); - return; - } - - done(emptyAfterOsanoReadyResult([GPP_COOKIE_NAME, GPP_SID_COOKIE_NAME])); - }); - } catch (error) { - log.debug('osano: __gpp ping failed', { error }); - done(pendingResult()); - } - }); -} - -function readTcfSignal(win: OsanoWindow): Promise { - if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); - - return new Promise((resolve) => { - const done = finishOnce((result: SignalResult) => { - window.clearTimeout(timer); - resolve(result); - }); - const timer = window.setTimeout(() => done(pendingResult()), API_TIMEOUT_MS); - - try { - win.__tcfapi?.('getTCData', 2, (data, success) => { - if (success === false || !isRecord(data)) { - done(pendingResult()); - return; - } - - if (!isTcfReady(data.eventStatus)) { - done(pendingResult()); - return; - } - - if ('tcString' in data && typeof data.tcString !== 'string') { - done(pendingResult()); - return; - } - - if (typeof data.tcString === 'string' && data.tcString.length > 0) { - done(signalResult([{ name: TCF_COOKIE_NAME, value: data.tcString }])); - return; - } - - done(emptyAfterOsanoReadyResult(TCF_COOKIE_NAME)); - }); - } catch (error) { - log.debug('osano: __tcfapi getTCData failed', { error }); - done(pendingResult()); - } - }); -} - -async function buildMirrorPlan(win: OsanoWindow): Promise { - const results = await Promise.all([readUspSignal(win), readGppSignal(win), readTcfSignal(win)]); - - return { - writes: results.flatMap((result) => result.writes), - clears: results.flatMap((result) => result.clears), - pending: results.some((result) => result.pending), - }; -} - -function applyMirrorPlan(plan: MirrorPlan): boolean { - if (plan.writes.length === 0 && plan.clears.length === 0) { - return false; - } - - if (!canWriteConsentCookies()) { - return false; - } - - const writeNames = new Set(plan.writes.map((write) => write.name)); - for (const name of plan.clears) { - if (!writeNames.has(name)) clearCookie(name); - } - - for (const write of plan.writes) { - writeCookie(write.name, write.value); - } - - if (hasAnyTargetCookie()) { - writeCookie(MARKER_COOKIE_NAME, MARKER_COOKIE_VALUE); - } else if (ownsConsentCookies()) { - clearCookie(MARKER_COOKIE_NAME); - } - - log.info('osano: mirrored consent to standard cookies', { - writes: plan.writes.map((write) => write.name), - clears: plan.clears, - pending: plan.pending, - }); - - return true; -} - -/** - * Mirrors Osano's IAB API consent signals into standard first-party cookies. - * - * Returns `true` when any cookie was written or cleared, `false` otherwise. - */ -export async function mirrorOsanoConsent(): Promise { - if (typeof document === 'undefined') return false; - - const win = getWindow(); - if (!win) return false; - - const generation = (mirrorGeneration += 1); - const plan = await buildMirrorPlan(win); - - if (generation !== mirrorGeneration) { - return false; - } - - return applyMirrorPlan(plan); -} - -function scheduleMirror(): void { - if (mirrorTimer !== undefined || typeof window === 'undefined') return; - - mirrorTimer = window.setTimeout(() => { - mirrorTimer = undefined; - void mirrorOsanoConsent(); - }, MIRROR_DEBOUNCE_MS); -} - -function installOsanoListeners(): boolean { - const cm = getWindow()?.Osano?.cm; - if (!cm) return false; - - if (osanoListenersInstalled) { - scheduleMirror(); - return true; - } - - if (typeof cm.addEventListener !== 'function') { - return false; - } - - osanoEventHandlers = new Map(); - for (const eventName of OSANO_EVENTS) { - const handler = (): void => { - if (OSANO_CLEAR_READY_EVENTS.has(eventName)) { - osanoReadyForClears = true; - } - scheduleMirror(); - }; - osanoEventHandlers.set(eventName, handler); - cm.addEventListener(eventName, handler); - } - osanoListenersInstalled = true; - - scheduleMirror(); - return true; -} - -function scheduleOsanoRetry(): void { - if (osanoRetryTimer !== undefined || osanoRetryCount >= OSANO_MAX_RETRIES) return; - - osanoRetryCount += 1; - osanoRetryTimer = window.setTimeout(() => { - osanoRetryTimer = undefined; - if (!installOsanoListeners()) { - scheduleOsanoRetry(); - } - }, OSANO_RETRY_DELAY_MS); -} - -function mirrorOnVisible(): void { - if (document.visibilityState === 'visible') { - scheduleMirror(); - } -} - -/** - * Initializes the Osano consent mirror. - */ -export function initializeOsanoConsentMirror(): void { - if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - initialized = true; - focusHandler = () => scheduleMirror(); - visibilityHandler = () => mirrorOnVisible(); - window.addEventListener('focus', focusHandler); - document.addEventListener('visibilitychange', visibilityHandler); - - scheduleMirror(); - - if (!installOsanoListeners()) { - scheduleOsanoRetry(); - } -} - -/** Resets module state for unit tests. */ -export function resetOsanoConsentMirrorForTest(): void { - const cm = getWindow()?.Osano?.cm; - if ( - osanoListenersInstalled && - osanoEventHandlers && - cm && - typeof cm.removeEventListener === 'function' - ) { - for (const [eventName, handler] of osanoEventHandlers) { - cm.removeEventListener(eventName, handler); - } - } - - if (focusHandler) window.removeEventListener('focus', focusHandler); - if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler); - if (osanoRetryTimer !== undefined) window.clearTimeout(osanoRetryTimer); - if (mirrorTimer !== undefined) window.clearTimeout(mirrorTimer); - - initialized = false; - osanoListenersInstalled = false; - osanoRetryCount = 0; - osanoReadyForClears = false; - mirrorGeneration = 0; - osanoRetryTimer = undefined; - mirrorTimer = undefined; - osanoEventHandlers = undefined; - focusHandler = undefined; - visibilityHandler = undefined; -} +import { initializeOsanoConsentMirror } from './consent_mirror'; +// Legacy entry point retained until the coordinated Task 19 wiring cutover. initializeOsanoConsentMirror(); diff --git a/crates/trusted-server-js/lib/src/integrations/osano/module.ts b/crates/trusted-server-js/lib/src/integrations/osano/module.ts new file mode 100644 index 000000000..2e81f6b49 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/osano/module.ts @@ -0,0 +1,49 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; + +import { disposeOsanoConsentMirror, initializeOsanoConsentMirror } from './consent_mirror'; + +export const OSANO_INTEGRATION_ID = 'osano' as const; + +export interface OsanoRuntimeDependencies { + readonly initialize: () => void; + readonly reset: () => void; +} + +/** Bind the existing consent mirror's complete lifecycle to one release. */ +export function createOsanoRuntime( + dependencies: OsanoRuntimeDependencies = { + initialize: initializeOsanoConsentMirror, + reset: disposeOsanoConsentMirror, + } +): IntegrationLifecycleRuntime { + let active = false; + let started = false; + return Object.freeze({ + activate: (_config: unknown) => { + if (active) throw new Error('Osano runtime is already active'); + active = true; + started = false; + return (): void => { + if (!active) return; + active = false; + started = false; + dependencies.reset(); + }; + }, + start: (_config: unknown) => { + if (!active || started) return; + started = true; + dependencies.initialize(); + }, + }); +} + +export function createOsanoIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(OSANO_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/module.ts b/crates/trusted-server-js/lib/src/integrations/permutive/module.ts new file mode 100644 index 000000000..9ccadbfa1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/permutive/module.ts @@ -0,0 +1,210 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +import { installPermutiveGuard, resetGuardState } from './script_guard'; +import { getPermutiveSegments } from './segments'; + +export const PERMUTIVE_INTEGRATION_ID = 'permutive' as const; + +const PERMUTIVE_CONFIG_FIELDS = [ + 'apiHost', + 'apiProtocol', + 'cdnBaseUrl', + 'cdnProtocol', + 'secureSignalsApiHost', + 'segmentSyncApiHost', +] as const; + +type PermutiveConfigField = (typeof PERMUTIVE_CONFIG_FIELDS)[number]; +type PermutiveConfig = Record; + +interface PermutiveSdk { + readonly config: PermutiveConfig; +} + +type ContextContributor = () => Readonly> | undefined; + +export interface PermutiveRuntimeDependencies { + readonly clearTimeout: (timer: number) => void; + readonly getSdk: () => PermutiveSdk | undefined; + readonly getSegments: () => readonly string[]; + readonly installGuard: () => void; + readonly location: { readonly host: string; readonly protocol: string }; + readonly registerContext: (contributor: ContextContributor) => (() => void) | undefined; + readonly resetGuard: () => void; + readonly setTimeout: (callback: () => void, delay: number) => number; + readonly started: () => void; + readonly timedOut: () => void; +} + +function bestEffort(action: () => void): void { + try { + action(); + } catch { + // Cleanup is intentionally isolated so one failed release cannot retain another resource. + } +} + +function snapshotSegments(candidate: readonly string[]): readonly string[] { + const segments: string[] = []; + try { + const length = Math.min(candidate.length, 100); + for (let index = 0; index < length; index += 1) { + const segment = candidate[index]; + if (typeof segment === 'string') segments.push(segment); + } + } catch { + return Object.freeze([]); + } + return Object.freeze(segments); +} + +/** Own the Permutive guard, auction context, SDK readiness timer, and rewritten config. */ +export function createPermutiveRuntime( + overrides: Partial = {} +): IntegrationLifecycleRuntime { + const dependencies: PermutiveRuntimeDependencies = { + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (globalThis as typeof globalThis & { permutive?: PermutiveSdk }).permutive, + getSegments: getPermutiveSegments, + installGuard: installPermutiveGuard, + location: window.location, + registerContext: () => undefined, + resetGuard: resetGuardState, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: () => log.info('Permutive integration initialized'), + timedOut: () => log.warn('Permutive SDK not detected after', 2_500, 'ms'), + ...overrides, + }; + let active = false; + let started = false; + let timer: number | undefined; + let releaseContext: (() => void) | undefined; + let ownedConfig: PermutiveConfig | undefined; + let installedValues: Readonly | undefined; + let previousValues: Readonly | undefined; + + const resetSdk = (): void => { + const config = ownedConfig; + const installed = installedValues; + const previous = previousValues; + ownedConfig = undefined; + installedValues = undefined; + previousValues = undefined; + if (!config || !installed || !previous) return; + + for (const field of PERMUTIVE_CONFIG_FIELDS) { + bestEffort(() => { + if (config[field] === installed[field]) config[field] = previous[field]; + }); + } + }; + + const installSdkConfig = (config: PermutiveConfig): boolean => { + const protocol = dependencies.location.protocol === 'https:' ? 'https' : 'http'; + const host = dependencies.location.host; + const next: PermutiveConfig = { + apiHost: `${host}/integrations/permutive/api`, + apiProtocol: protocol, + cdnBaseUrl: `${host}/integrations/permutive/cdn`, + cdnProtocol: protocol, + secureSignalsApiHost: `${host}/integrations/permutive/secure-signal`, + segmentSyncApiHost: `${host}/integrations/permutive/sync`, + }; + const previous = {} as PermutiveConfig; + const written: PermutiveConfigField[] = []; + try { + for (const field of PERMUTIVE_CONFIG_FIELDS) previous[field] = config[field]; + for (const field of PERMUTIVE_CONFIG_FIELDS) { + config[field] = next[field]; + written.push(field); + } + } catch { + for (const field of written.reverse()) { + bestEffort(() => { + if (config[field] === next[field]) config[field] = previous[field]; + }); + } + return false; + } + ownedConfig = config; + previousValues = Object.freeze({ ...previous }); + installedValues = Object.freeze({ ...next }); + return true; + }; + + return Object.freeze({ + activate: (_config: unknown): (() => void) => { + if (active) throw new Error('Permutive runtime is already active'); + try { + dependencies.installGuard(); + releaseContext = dependencies.registerContext(() => { + try { + const segments = snapshotSegments(dependencies.getSegments()); + if (segments.length === 0) return undefined; + return Object.freeze({ permutive_segments: segments }); + } catch { + return undefined; + } + }); + if (!releaseContext) throw new Error('Permutive context registration failed'); + } catch (error) { + releaseContext = undefined; + bestEffort(dependencies.resetGuard); + throw error; + } + active = true; + return (): void => { + if (!active) return; + active = false; + started = false; + if (timer !== undefined) { + bestEffort(() => dependencies.clearTimeout(timer as number)); + timer = undefined; + } + resetSdk(); + const release = releaseContext; + releaseContext = undefined; + if (release) bestEffort(release); + bestEffort(dependencies.resetGuard); + }; + }, + start: (_config: unknown): void => { + if (!active || started) return; + started = true; + dependencies.started(); + let attempts = 0; + const check = (): void => { + timer = undefined; + if (!active) return; + attempts += 1; + try { + const sdk = dependencies.getSdk(); + if (sdk?.config && installSdkConfig(sdk.config)) return; + } catch { + // Treat an unreadable SDK as not ready. + } + if (attempts >= 50) { + dependencies.timedOut(); + return; + } + try { + timer = dependencies.setTimeout(check, 50); + } catch { + dependencies.timedOut(); + } + }; + check(); + }, + }); +} + +export function createPermutiveIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(PERMUTIVE_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts new file mode 100644 index 000000000..7dc6e6b3a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_mirror.ts @@ -0,0 +1,299 @@ +import { log } from '../../core/log'; + +const SP_CONSENT_PREFIX = '_sp_user_consent_'; +const GPP_COOKIE_NAME = '__gpp'; +const GPP_SID_COOKIE_NAME = '__gpp_sid'; +const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; +const GPP_SOURCE_SOURCEPOINT = 'sp'; +const INITIAL_RETRY_DELAY_MS = 500; + +interface SourcepointGppData { + gppString?: string | undefined; + applicableSections?: number[] | undefined; +} + +interface SourcepointConsentStringEntry { + sectionId?: number | undefined; +} + +interface SourcepointSectionPayload { + consentString?: string | undefined; + applicableSections?: number[] | undefined; + consentStrings?: SourcepointConsentStringEntry[] | undefined; +} + +interface SourcepointConsentPayload { + gppData?: SourcepointGppData | undefined; + [key: string]: unknown; +} + +interface MirroredSourcepointConsent { + gppString: string; + applicableSections?: number[] | undefined; +} + +let initialized = false; +let initialRetryDone = false; +let retryTimer: number | undefined; +let domContentLoadedHandler: (() => void) | undefined; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === 'number'); +} + +function isConsentStringEntryArray(value: unknown): value is SourcepointConsentStringEntry[] { + return ( + Array.isArray(value) && + value.every( + (item) => + isRecord(item) && + (typeof item.sectionId === 'number' || typeof item.sectionId === 'undefined') + ) + ); +} + +function normalizeSectionPayload(value: unknown): SourcepointSectionPayload | null { + if (!isRecord(value)) return null; + + return { + consentString: typeof value.consentString === 'string' ? value.consentString : undefined, + applicableSections: isNumberArray(value.applicableSections) + ? value.applicableSections + : undefined, + consentStrings: isConsentStringEntryArray(value.consentStrings) + ? value.consentStrings + : undefined, + }; +} + +function sectionIdsFromConsentStrings( + consentStrings: SourcepointConsentStringEntry[] | undefined +): number[] | undefined { + const ids = consentStrings + ?.map((entry) => entry.sectionId) + .filter((sectionId): sectionId is number => typeof sectionId === 'number'); + + return ids && ids.length > 0 ? ids : undefined; +} + +function looksLikeGpp(consentString: string): boolean { + return consentString.includes('~'); +} + +function extractMirroredConsent( + payload: SourcepointConsentPayload +): MirroredSourcepointConsent | null { + if (payload.gppData?.gppString) { + return { + gppString: payload.gppData.gppString, + applicableSections: payload.gppData.applicableSections, + }; + } + + for (const [sectionName, rawSection] of Object.entries(payload)) { + if (sectionName === 'gppData') continue; + + const section = normalizeSectionPayload(rawSection); + if (!section?.consentString || !looksLikeGpp(section.consentString)) continue; + + return { + gppString: section.consentString, + applicableSections: + section.applicableSections ?? sectionIdsFromConsentStrings(section.consentStrings), + }; + } + + return null; +} + +function findSourcepointConsent(): MirroredSourcepointConsent | null { + // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. + // We intentionally take the first valid match and mirror that origin-scoped payload. + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + const payload = JSON.parse(raw) as SourcepointConsentPayload; + const consent = extractMirroredConsent(payload); + if (consent) { + return consent; + } + } catch { + log.debug('sourcepoint: failed to parse localStorage value', { key }); + } + } + return null; +} + +function readCookie(name: string): string | undefined { + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + return cookie?.slice(prefix.length); +} + +function hasSourcepointMarker(): boolean { + return readCookie(GPP_SOURCE_COOKIE_NAME) === GPP_SOURCE_SOURCEPOINT; +} + +function writeCookie(name: string, value: string): void { + document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; +} + +function clearCookie(name: string): void { + document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; +} + +function clearSourcepointCookies(): void { + if (!hasSourcepointMarker()) { + return; + } + + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + clearCookie(GPP_SOURCE_COOKIE_NAME); +} + +function mirrorOnVisible(): void { + if (document.visibilityState === 'visible') { + mirrorSourcepointConsent(); + } +} + +function clearInitialRetryTimer(): void { + if (retryTimer === undefined) { + return; + } + + window.clearTimeout(retryTimer); + retryTimer = undefined; +} + +function clearDomContentLoadedHandler(): void { + if (!domContentLoadedHandler) return; + document.removeEventListener('DOMContentLoaded', domContentLoadedHandler); + domContentLoadedHandler = undefined; +} + +function scheduleInitialRetry(): void { + if (initialRetryDone || retryTimer !== undefined) { + return; + } + + const retry = (): void => { + if (initialRetryDone) { + return; + } + + initialRetryDone = true; + clearInitialRetryTimer(); + clearDomContentLoadedHandler(); + mirrorSourcepointConsent(); + }; + + if (document.readyState === 'loading') { + domContentLoadedHandler = retry; + document.addEventListener('DOMContentLoaded', retry, { once: true }); + } + + retryTimer = window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); +} + +/** + * Reads Sourcepoint consent from localStorage and mirrors it into + * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. + * + * Sourcepoint stores different shapes depending on the campaign/module. US + * National data is commonly stored under `usnat.consentString` and + * `usnat.applicableSections`, while some setups expose `gppData.gppString`. + * + * Returns `true` if cookies were written, `false` otherwise. + */ +export function mirrorSourcepointConsent(): boolean { + if (typeof localStorage === 'undefined' || typeof document === 'undefined') { + return false; + } + + const consent = findSourcepointConsent(); + if (!consent) { + clearSourcepointCookies(); + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + + const { gppString, applicableSections } = consent; + if (!gppString) { + clearSourcepointCookies(); + log.debug('sourcepoint: gppString is empty'); + return false; + } + + const existingGppCookie = readCookie(GPP_COOKIE_NAME); + if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { + log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); + return false; + } + + writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); + writeCookie(GPP_COOKIE_NAME, gppString); + + if (Array.isArray(applicableSections) && applicableSections.length > 0) { + writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); + } else { + clearCookie(GPP_SID_COOKIE_NAME); + } + + initialRetryDone = true; + clearInitialRetryTimer(); + clearDomContentLoadedHandler(); + + log.info('sourcepoint: mirrored GPP consent to cookies', { + gppLength: gppString.length, + sections: applicableSections, + }); + + return true; +} + +/** + * Initializes Sourcepoint consent mirroring and bounded refresh hooks. + */ +export function initializeSourcepointConsentMirror(): void { + if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + initialized = true; + + if (!mirrorSourcepointConsent()) { + scheduleInitialRetry(); + } + + // Sourcepoint persists consent changes to localStorage. Re-mirror when a + // user returns to the page so session cookies do not remain stale. + document.addEventListener('visibilitychange', mirrorOnVisible); + window.addEventListener('focus', mirrorSourcepointConsent); +} + +/** Dispose every timer/listener owned by the active Sourcepoint consent mirror. */ +export function disposeSourcepointConsentMirror(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('focus', mirrorSourcepointConsent); + clearInitialRetryTimer(); + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', mirrorOnVisible); + clearDomContentLoadedHandler(); + } + initialized = false; + initialRetryDone = false; + retryTimer = undefined; + domContentLoadedHandler = undefined; +} 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 9e181c94d..442d14760 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -1,299 +1,23 @@ import { log } from '../../core/log'; +import { initializeSourcepointConsentMirror } from './consent_mirror'; import { installSourcepointGuard } from './script_guard'; +export { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, + mirrorSourcepointConsent, +} from './consent_mirror'; + type SourcepointWindow = Window & { - __tsjs_sourcepoint?: - | { - rewriteSdk?: boolean | undefined; - } - | undefined; + __tsjs_sourcepoint?: { rewriteSdk?: boolean }; }; -function shouldInstallSourcepointGuard(): boolean { - if (typeof window === 'undefined') { - return false; +// Legacy entry point retained until the coordinated Task 19 wiring cutover. +if (typeof window !== 'undefined') { + if ((window as SourcepointWindow).__tsjs_sourcepoint?.rewriteSdk !== false) { + installSourcepointGuard(); } - - const config = (window as SourcepointWindow).__tsjs_sourcepoint; - return config?.rewriteSdk !== false; -} - -if (typeof window !== 'undefined' && shouldInstallSourcepointGuard()) { - installSourcepointGuard(); + initializeSourcepointConsentMirror(); log.info('Sourcepoint integration initialized'); } - -const SP_CONSENT_PREFIX = '_sp_user_consent_'; -const GPP_COOKIE_NAME = '__gpp'; -const GPP_SID_COOKIE_NAME = '__gpp_sid'; -const GPP_SOURCE_COOKIE_NAME = '_ts_gpp_src'; -const GPP_SOURCE_SOURCEPOINT = 'sp'; -const INITIAL_RETRY_DELAY_MS = 500; - -interface SourcepointGppData { - gppString?: string | undefined; - applicableSections?: number[] | undefined; -} - -interface SourcepointConsentStringEntry { - sectionId?: number | undefined; -} - -interface SourcepointSectionPayload { - consentString?: string | undefined; - applicableSections?: number[] | undefined; - consentStrings?: SourcepointConsentStringEntry[] | undefined; -} - -interface SourcepointConsentPayload { - gppData?: SourcepointGppData | undefined; - [key: string]: unknown; -} - -interface MirroredSourcepointConsent { - gppString: string; - applicableSections?: number[] | undefined; -} - -let initialized = false; -let initialRetryDone = false; -let retryTimer: number | undefined; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isNumberArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((item) => typeof item === 'number'); -} - -function isConsentStringEntryArray(value: unknown): value is SourcepointConsentStringEntry[] { - return ( - Array.isArray(value) && - value.every( - (item) => - isRecord(item) && - (typeof item.sectionId === 'number' || typeof item.sectionId === 'undefined') - ) - ); -} - -function normalizeSectionPayload(value: unknown): SourcepointSectionPayload | null { - if (!isRecord(value)) return null; - - return { - consentString: typeof value.consentString === 'string' ? value.consentString : undefined, - applicableSections: isNumberArray(value.applicableSections) - ? value.applicableSections - : undefined, - consentStrings: isConsentStringEntryArray(value.consentStrings) - ? value.consentStrings - : undefined, - }; -} - -function sectionIdsFromConsentStrings( - consentStrings: SourcepointConsentStringEntry[] | undefined -): number[] | undefined { - const ids = consentStrings - ?.map((entry) => entry.sectionId) - .filter((sectionId): sectionId is number => typeof sectionId === 'number'); - - return ids && ids.length > 0 ? ids : undefined; -} - -function looksLikeGpp(consentString: string): boolean { - return consentString.includes('~'); -} - -function extractMirroredConsent( - payload: SourcepointConsentPayload -): MirroredSourcepointConsent | null { - if (payload.gppData?.gppString) { - return { - gppString: payload.gppData.gppString, - applicableSections: payload.gppData.applicableSections, - }; - } - - for (const [sectionName, rawSection] of Object.entries(payload)) { - if (sectionName === 'gppData') continue; - - const section = normalizeSectionPayload(rawSection); - if (!section?.consentString || !looksLikeGpp(section.consentString)) continue; - - return { - gppString: section.consentString, - applicableSections: - section.applicableSections ?? sectionIdsFromConsentStrings(section.consentStrings), - }; - } - - return null; -} - -function findSourcepointConsent(): MirroredSourcepointConsent | null { - // Sourcepoint stores one consent payload per property under `_sp_user_consent_*`. - // We intentionally take the first valid match and mirror that origin-scoped payload. - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(SP_CONSENT_PREFIX)) continue; - - const raw = localStorage.getItem(key); - if (!raw) continue; - - try { - const payload = JSON.parse(raw) as SourcepointConsentPayload; - const consent = extractMirroredConsent(payload); - if (consent) { - return consent; - } - } catch { - log.debug('sourcepoint: failed to parse localStorage value', { key }); - } - } - return null; -} - -function readCookie(name: string): string | undefined { - const prefix = `${name}=`; - const cookie = document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); - return cookie?.slice(prefix.length); -} - -function hasSourcepointMarker(): boolean { - return readCookie(GPP_SOURCE_COOKIE_NAME) === GPP_SOURCE_SOURCEPOINT; -} - -function writeCookie(name: string, value: string): void { - document.cookie = `${name}=${value}; path=/; Secure; SameSite=Lax`; -} - -function clearCookie(name: string): void { - document.cookie = `${name}=; path=/; Secure; SameSite=Lax; Max-Age=0`; -} - -function clearSourcepointCookies(): void { - if (!hasSourcepointMarker()) { - return; - } - - clearCookie(GPP_COOKIE_NAME); - clearCookie(GPP_SID_COOKIE_NAME); - clearCookie(GPP_SOURCE_COOKIE_NAME); -} - -function mirrorOnVisible(): void { - if (document.visibilityState === 'visible') { - mirrorSourcepointConsent(); - } -} - -function clearInitialRetryTimer(): void { - if (retryTimer === undefined) { - return; - } - - window.clearTimeout(retryTimer); - retryTimer = undefined; -} - -function scheduleInitialRetry(): void { - if (initialRetryDone || retryTimer !== undefined) { - return; - } - - const retry = (): void => { - if (initialRetryDone) { - return; - } - - initialRetryDone = true; - clearInitialRetryTimer(); - mirrorSourcepointConsent(); - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', retry, { once: true }); - } - - retryTimer = window.setTimeout(retry, INITIAL_RETRY_DELAY_MS); -} - -/** - * Reads Sourcepoint consent from localStorage and mirrors it into - * `__gpp` and `__gpp_sid` cookies for Trusted Server to read. - * - * Sourcepoint stores different shapes depending on the campaign/module. US - * National data is commonly stored under `usnat.consentString` and - * `usnat.applicableSections`, while some setups expose `gppData.gppString`. - * - * Returns `true` if cookies were written, `false` otherwise. - */ -export function mirrorSourcepointConsent(): boolean { - if (typeof localStorage === 'undefined' || typeof document === 'undefined') { - return false; - } - - const consent = findSourcepointConsent(); - if (!consent) { - clearSourcepointCookies(); - log.debug('sourcepoint: no GPP data found in localStorage'); - return false; - } - - const { gppString, applicableSections } = consent; - if (!gppString) { - clearSourcepointCookies(); - log.debug('sourcepoint: gppString is empty'); - return false; - } - - const existingGppCookie = readCookie(GPP_COOKIE_NAME); - if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { - log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); - return false; - } - - writeCookie(GPP_SOURCE_COOKIE_NAME, GPP_SOURCE_SOURCEPOINT); - writeCookie(GPP_COOKIE_NAME, gppString); - - if (Array.isArray(applicableSections) && applicableSections.length > 0) { - writeCookie(GPP_SID_COOKIE_NAME, applicableSections.join(',')); - } else { - clearCookie(GPP_SID_COOKIE_NAME); - } - - initialRetryDone = true; - clearInitialRetryTimer(); - - log.info('sourcepoint: mirrored GPP consent to cookies', { - gppLength: gppString.length, - sections: applicableSections, - }); - - return true; -} - -/** - * Initializes Sourcepoint consent mirroring and bounded refresh hooks. - */ -export function initializeSourcepointConsentMirror(): void { - if (initialized || typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - initialized = true; - - if (!mirrorSourcepointConsent()) { - scheduleInitialRetry(); - } - - // Sourcepoint persists consent changes to localStorage. Re-mirror when a - // user returns to the page so session cookies do not remain stale. - document.addEventListener('visibilitychange', mirrorOnVisible); - window.addEventListener('focus', mirrorSourcepointConsent); -} - -initializeSourcepointConsentMirror(); diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts new file mode 100644 index 000000000..9e5c715b1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts @@ -0,0 +1,106 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; + +import { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, +} from './consent_mirror'; +import { installSourcepointGuard, resetGuardState } from './script_guard'; + +export const SOURCEPOINT_INTEGRATION_ID = 'sourcepoint' as const; + +interface SourcepointBootConfig { + readonly rewriteSdk: boolean; +} + +export interface SourcepointRuntimeDependencies { + readonly initializeConsentMirror: () => void; + readonly installGuard: () => void; + readonly resetConsentMirror: () => void; + readonly resetGuard: () => void; +} + +function sourcepointBootConfig(candidate: unknown): candidate is SourcepointBootConfig { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Reflect.ownKeys(candidate).length !== 1 + ) { + return false; + } + const rewriteSdk = Object.getOwnPropertyDescriptor(candidate, 'rewriteSdk'); + return Boolean( + rewriteSdk?.enumerable && 'value' in rewriteSdk && typeof rewriteSdk.value === 'boolean' + ); + } catch { + return false; + } +} + +/** Own Sourcepoint's optional guard and consent mirror as one release-bound unit. */ +export function createSourcepointRuntime( + dependencies: SourcepointRuntimeDependencies = { + initializeConsentMirror: initializeSourcepointConsentMirror, + installGuard: installSourcepointGuard, + resetConsentMirror: disposeSourcepointConsentMirror, + resetGuard: resetGuardState, + } +): IntegrationLifecycleRuntime { + let active = false; + let guardInstalled = false; + let started = false; + return Object.freeze({ + activate: (candidate: unknown) => { + if (!sourcepointBootConfig(candidate)) { + throw new TypeError('Sourcepoint integration config is invalid'); + } + if (active) throw new Error('Sourcepoint runtime is already active'); + if (candidate.rewriteSdk) { + try { + dependencies.installGuard(); + guardInstalled = true; + } catch (error) { + try { + dependencies.resetGuard(); + } catch { + // Preserve the activation failure after best-effort rollback. + } + throw error; + } + } + active = true; + started = false; + return (): void => { + if (!active) return; + active = false; + started = false; + try { + dependencies.resetConsentMirror(); + } finally { + if (guardInstalled) { + guardInstalled = false; + dependencies.resetGuard(); + } + } + }; + }, + start: (_config: unknown) => { + if (!active || started) return; + started = true; + dependencies.initializeConsentMirror(); + }, + }); +} + +export function createSourcepointIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(SOURCEPOINT_INTEGRATION_ID, release, { + validateConfig: sourcepointBootConfig, + }); +} diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/module.ts b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts new file mode 100644 index 000000000..dbbab6951 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts @@ -0,0 +1,224 @@ +import type { IntegrationRegistration } from '../../kernel/integration_registry'; +import { + createLifecycleIntegrationRegistration, + type IntegrationLifecycleRuntime, +} from '../../kernel/lifecycle_module'; +import { log } from '../../core/log'; + +export const TESTLIGHT_INTEGRATION_ID = 'testlight' as const; + +interface TestlightGlobal { + que?: unknown[] | undefined; +} + +interface TestlightTarget { + testlight?: TestlightGlobal | undefined; +} + +export interface TestlightRuntimeDependencies { + readonly enqueue: (callback: () => void) => void; + readonly started: () => void; + readonly target: TestlightTarget; +} + +function callableQueue(candidate: unknown): candidate is { push: (entry: unknown) => number } { + return ( + (typeof candidate === 'object' || typeof candidate === 'function') && + candidate !== null && + typeof (candidate as { push?: unknown }).push === 'function' + ); +} + +function ownQueueValues(candidate: unknown): unknown[] { + if (!Array.isArray(candidate)) return []; + const entries: Array = []; + try { + for (const key of Reflect.ownKeys(candidate)) { + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= 4_294_967_295) continue; + const descriptor = Object.getOwnPropertyDescriptor(candidate, key); + if (descriptor && descriptor.enumerable && 'value' in descriptor) { + entries.push([index, descriptor.value]); + } + } + } catch { + return []; + } + entries.sort(([left], [right]) => left - right); + return entries.map(([, value]) => value); +} + +/** Own Testlight's callback bridge without retaining callbacks after TSJS commit. */ +export function createTestlightRuntime( + dependencies: TestlightRuntimeDependencies = { + enqueue: (callback) => { + const queue = (window as typeof window & { tsjs?: { que?: unknown } }).tsjs?.que; + if (!callableQueue(queue)) throw new Error('Testlight TSJS queue is unavailable'); + queue.push(callback); + }, + started: () => log.info('Testlight integration initialized'), + target: window as typeof window & TestlightTarget, + } +): IntegrationLifecycleRuntime { + let active = false; + let started = false; + let ownedGlobal: TestlightGlobal | undefined; + let installedQueue: unknown[] | undefined; + let previousQueueDescriptor: PropertyDescriptor | undefined; + let previousTargetDescriptor: PropertyDescriptor | undefined; + let createdGlobal = false; + let forwarding = false; + let originalQueue: unknown[] | undefined; + let originalQueueLength = 0; + + const releaseOwnership = (): void => { + const global = ownedGlobal; + const queue = installedQueue; + const queueDescriptor = previousQueueDescriptor; + const targetDescriptor = previousTargetDescriptor; + const removeGlobal = createdGlobal; + const restoreQueue = originalQueue; + const restoreQueueLength = originalQueueLength; + const shouldReturnPending = !forwarding; + ownedGlobal = undefined; + installedQueue = undefined; + previousQueueDescriptor = undefined; + previousTargetDescriptor = undefined; + createdGlobal = false; + forwarding = false; + originalQueue = undefined; + originalQueueLength = 0; + + if (global && queue) { + try { + if (Object.getOwnPropertyDescriptor(global, 'que')?.value === queue) { + if (shouldReturnPending && restoreQueue) { + const later = ownQueueValues(queue).slice(restoreQueueLength); + Array.prototype.push.apply(restoreQueue, later); + } + if (queueDescriptor) Object.defineProperty(global, 'que', queueDescriptor); + else Reflect.deleteProperty(global, 'que'); + } + } catch { + // Publisher replacement wins over cleanup. + } + } + if (removeGlobal) { + try { + if ( + Object.getOwnPropertyDescriptor(dependencies.target, 'testlight')?.value === global && + global && + Reflect.ownKeys(global).length === 0 + ) { + if (targetDescriptor) { + Object.defineProperty(dependencies.target, 'testlight', targetDescriptor); + } else { + Reflect.deleteProperty(dependencies.target, 'testlight'); + } + } + } catch { + // Publisher replacement wins over cleanup. + } + } + }; + + return Object.freeze({ + activate: (_config: unknown): (() => void) => { + if (active) throw new Error('Testlight runtime is already active'); + try { + previousTargetDescriptor = Object.getOwnPropertyDescriptor( + dependencies.target, + 'testlight' + ); + if (previousTargetDescriptor && !('value' in previousTargetDescriptor)) { + throw new TypeError('Testlight publisher global accessor is unsupported'); + } + const currentGlobal = previousTargetDescriptor?.value; + const global = + typeof currentGlobal === 'object' && currentGlobal !== null ? currentGlobal : {}; + createdGlobal = global !== currentGlobal; + if (createdGlobal) dependencies.target.testlight = global; + ownedGlobal = global; + + previousQueueDescriptor = Object.getOwnPropertyDescriptor(global, 'que'); + if (previousQueueDescriptor && !('value' in previousQueueDescriptor)) { + throw new TypeError('Testlight publisher queue accessor is unsupported'); + } + originalQueue = + previousQueueDescriptor && + 'value' in previousQueueDescriptor && + Array.isArray(previousQueueDescriptor.value) + ? previousQueueDescriptor.value + : undefined; + const queue = ownQueueValues(originalQueue); + originalQueueLength = queue.length; + Object.defineProperty(global, 'que', { + configurable: true, + enumerable: true, + value: queue, + writable: true, + }); + installedQueue = queue; + forwarding = false; + active = true; + started = false; + } catch (error) { + releaseOwnership(); + throw error; + } + return (): void => { + if (!active) return; + active = false; + started = false; + releaseOwnership(); + }; + }, + start: (_config: unknown): void => { + if (!active || started) return; + started = true; + dependencies.started(); + + try { + const queue = installedQueue; + if ( + !queue || + !ownedGlobal || + Object.getOwnPropertyDescriptor(ownedGlobal, 'que')?.value !== queue + ) { + return; + } + const pending = ownQueueValues(queue); + queue.length = 0; + Object.defineProperty(queue, 'push', { + configurable: true, + enumerable: false, + value: (...candidates: unknown[]): number => { + for (const candidate of candidates) { + if (typeof candidate !== 'function') continue; + try { + dependencies.enqueue(candidate as () => void); + log.debug('testlight shim: flushed callback'); + } catch (error) { + log.debug('testlight shim: queued callback threw', error); + } + } + return 0; + }, + writable: false, + }); + forwarding = true; + for (const candidate of pending) queue.push(candidate); + } catch (error) { + releaseOwnership(); + throw error; + } + }, + }); +} + +export function createTestlightIntegrationRegistration(release: string): IntegrationRegistration { + return createLifecycleIntegrationRegistration(TESTLIGHT_INTEGRATION_ID, release, { + validateConfig: (candidate) => candidate === undefined, + }); +} diff --git a/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts b/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts new file mode 100644 index 000000000..355b6cd32 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts @@ -0,0 +1,132 @@ +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from './integration_registry'; + +const MAX_CONFIG_DEPTH = 16; +const MAX_CONFIG_NODES = 512; +const MAX_CONFIG_MEMBERS = 256; + +export interface IntegrationLifecycleRuntime { + readonly activate: (config: unknown) => () => void; + readonly start: (config: unknown) => void; +} + +export interface LifecycleIntegrationRegistrationOptions { + readonly validateConfig?: (candidate: unknown) => boolean; +} + +function validFrozenConfig(candidate: unknown): boolean { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + !Object.isFrozen(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype + ) { + return false; + } + const visited = new Set(); + let nodes = 0; + const visit = (value: unknown, depth: number): boolean => { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return true; + if (typeof value === 'function' || depth > MAX_CONFIG_DEPTH || visited.has(value)) return false; + if (nodes >= MAX_CONFIG_NODES || !Object.isFrozen(value)) return false; + nodes += 1; + visited.add(value); + const isArray = Array.isArray(value); + if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) return false; + const keys = Reflect.ownKeys(value); + if (keys.length > MAX_CONFIG_MEMBERS + (isArray ? 1 : 0)) return false; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== 'string') return false; + if (isArray && key === 'length') continue; + if (isArray && key !== String(index)) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return false; + if (!visit(descriptor.value, depth + 1)) return false; + } + return true; + }; + try { + return visit(candidate, 0); + } catch { + return false; + } +} + +function readRuntime( + id: string, + interfaces: Readonly> +): IntegrationLifecycleRuntime | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(interfaces, id); + if (!descriptor || !('value' in descriptor)) return undefined; + const runtime = descriptor.value; + if ( + typeof runtime !== 'object' || + runtime === null || + Array.isArray(runtime) || + !Object.isFrozen(runtime) || + Reflect.ownKeys(runtime).length !== 2 + ) { + return undefined; + } + const activate = Object.getOwnPropertyDescriptor(runtime, 'activate'); + const start = Object.getOwnPropertyDescriptor(runtime, 'start'); + if ( + !activate || + !('value' in activate) || + typeof activate.value !== 'function' || + !start || + !('value' in start) || + typeof start.value !== 'function' + ) { + return undefined; + } + return runtime as IntegrationLifecycleRuntime; + } catch { + return undefined; + } +} + +/** Build a release-bound registration around one exact composition-owned runtime. */ +export function createLifecycleIntegrationRegistration( + id: string, + release: string, + options: LifecycleIntegrationRegistrationOptions = {} +): IntegrationRegistration { + return Object.freeze({ + id, + release, + prepare: async ({ config, interfaces }: IntegrationPrepareContext) => { + const validateConfig = options.validateConfig ?? validFrozenConfig; + let configValid: boolean; + try { + configValid = validateConfig(config); + } catch { + configValid = false; + } + if (!configValid) { + throw new TypeError(`${id} integration config is invalid`); + } + const runtime = readRuntime(id, interfaces); + if (!runtime) throw new TypeError(`${id} integration runtime is unavailable`); + + return Object.freeze({ + activate: ({ afterCommit, onDispose }: IntegrationActivationContext) => { + const runtimeRelease: { value?: () => void } = {}; + onDispose(() => runtimeRelease.value?.()); + const releaseRuntime = runtime.activate(config); + if (typeof releaseRuntime !== 'function') { + throw new TypeError(`${id} integration disposer is unavailable`); + } + runtimeRelease.value = releaseRuntime; + afterCommit(() => runtime.start(config)); + }, + }); + }, + }); +} diff --git a/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts b/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts new file mode 100644 index 000000000..dd7a40b47 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/datadome/module.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDataDomeIntegrationRegistration, + createDataDomeRuntime, +} from '../../../src/integrations/datadome/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional DataDome integration module', () => { + it('prepares inertly, activates before publication, and releases exactly once', async () => { + const order: string[] = []; + const release = vi.fn(() => order.push('release')); + const runtime = Object.freeze({ + activate: vi.fn(() => { + order.push('datadome:activate'); + return release; + }), + start: vi.fn(() => order.push('datadome:start')), + }); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'datadome', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['datadome']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: undefined, + interfaces: Object.freeze({ datadome: runtime }), + }), + }); + registry.register(createDataDomeIntegrationRegistration(RELEASE_ID)); + + expect(runtime.activate).not.toHaveBeenCalled(); + expect(runtime.start).not.toHaveBeenCalled(); + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'datadome:activate', 'publish', 'datadome:start', 'drain']); + if (result.state === 'kernel') { + result.dispose(); + result.dispose(); + } + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([null, Object.freeze({}), false])('rejects non-absent config %j', async (config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'datadome', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['datadome']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + datadome: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createDataDomeIntegrationRegistration(RELEASE_ID)); + + await expect(registry.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('owns and reverses the concrete DataDome guard', () => { + const order: string[] = []; + const runtime = createDataDomeRuntime({ + installGuard: () => order.push('install'), + resetGuard: () => order.push('reset'), + started: () => order.push('started'), + }); + + const release = runtime.activate(undefined); + runtime.start(undefined); + release(); + release(); + + expect(order).toEqual(['install', 'started', 'reset']); + }); + + it('rolls back an attempted guard installation that throws', () => { + const resetGuard = vi.fn(); + const runtime = createDataDomeRuntime({ + installGuard: () => { + throw new Error('fictional guard failure'); + }, + resetGuard, + started: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('fictional guard failure'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts new file mode 100644 index 000000000..2a78398fa --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/didomi/module.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDidomiIntegrationRegistration, + createDidomiRuntime, +} from '../../../src/integrations/didomi/module'; +import { createIntegrationRegistry } from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +describe('transactional Didomi integration module', () => { + it('sets an absolute SDK path without clobbering publisher config and compare-restores it', () => { + const config = { custom: 'publisher', sdkPath: 'https://publisher.example/sdk/' }; + const target = { + didomiConfig: config, + location: { origin: 'https://news.example' }, + }; + const started = vi.fn(); + const runtime = createDidomiRuntime({ started, target }); + const boot = Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + + const release = runtime.activate(boot); + + expect(config).toEqual({ + custom: 'publisher', + sdkPath: 'https://news.example/integrations/didomi/consent/', + }); + runtime.start(boot); + expect(started).toHaveBeenCalledOnce(); + release(); + release(); + expect(config).toEqual({ custom: 'publisher', sdkPath: 'https://publisher.example/sdk/' }); + }); + + it('does not overwrite a publisher replacement during disposal', () => { + const config = { sdkPath: 'https://publisher.example/original/' }; + const runtime = createDidomiRuntime({ + started: vi.fn(), + target: { didomiConfig: config, location: { origin: 'https://news.example' } }, + }); + const release = runtime.activate(Object.freeze({ proxyPath: '/integrations/didomi/consent/' })); + config.sdkPath = 'https://publisher.example/replacement/'; + + release(); + + expect(config.sdkPath).toBe('https://publisher.example/replacement/'); + }); + + it.each([ + ['mutable', { proxyPath: '/integrations/didomi/consent/' }], + ['relative', Object.freeze({ proxyPath: 'integrations/didomi/consent/' })], + ['protocol relative', Object.freeze({ proxyPath: '//attacker.example/consent/' })], + ['backslash authority', Object.freeze({ proxyPath: '/\\attacker.example/consent/' })], + ['extra', Object.freeze({ proxyPath: '/integrations/didomi/consent/', legacy: true })], + ])('rejects %s boot config before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'didomi', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['didomi']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + didomi: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createDidomiIntegrationRegistration(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts new file mode 100644 index 000000000..ae835d668 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/module.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createGoogleTagManagerIntegrationRegistration, + createGoogleTagManagerRuntime, +} from '../../../src/integrations/google_tag_manager/module'; +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +describe('transactional Google Tag Manager integration module', () => { + it('activates both guards before publication and releases them in reverse order', async () => { + const order: string[] = []; + const runtime = createGoogleTagManagerRuntime({ + installBeaconGuard: () => order.push('beacon:install'), + installScriptGuard: () => order.push('script:install'), + resetBeaconGuard: () => order.push('beacon:reset'), + resetScriptGuard: () => order.push('script:reset'), + started: () => order.push('gtm:start'), + }); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'google_tag_manager', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['google_tag_manager']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: undefined, + interfaces: Object.freeze({ google_tag_manager: runtime }), + }), + }); + registry.register(createGoogleTagManagerIntegrationRegistration(RELEASE_ID)); + + expect(order).toEqual([]); + const result = await registry.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'core', + 'script:install', + 'beacon:install', + 'publish', + 'gtm:start', + 'drain', + ]); + if (result.state === 'kernel') result.dispose(); + expect(order.slice(-2)).toEqual(['beacon:reset', 'script:reset']); + }); + + it('rolls back the script guard when beacon activation throws', () => { + const resetBeaconGuard = vi.fn(); + const resetScriptGuard = vi.fn(); + const runtime = createGoogleTagManagerRuntime({ + installBeaconGuard: () => { + throw new Error('fictional beacon failure'); + }, + installScriptGuard: vi.fn(), + resetBeaconGuard, + resetScriptGuard, + started: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('fictional beacon failure'); + expect(resetBeaconGuard).toHaveBeenCalledOnce(); + expect(resetScriptGuard).toHaveBeenCalledOnce(); + }); + + it('rolls back an attempted script guard installation that throws', () => { + const resetBeaconGuard = vi.fn(); + const resetScriptGuard = vi.fn(); + const runtime = createGoogleTagManagerRuntime({ + installBeaconGuard: vi.fn(), + installScriptGuard: () => { + throw new Error('fictional script failure'); + }, + resetBeaconGuard, + resetScriptGuard, + started: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('fictional script failure'); + expect(resetBeaconGuard).not.toHaveBeenCalled(); + expect(resetScriptGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts new file mode 100644 index 000000000..5bef06a62 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lifecycle_modules.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import { + createIntegrationRegistry, + type IntegrationRegistration, +} from '../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); +const registrations: ReadonlyArray< + readonly [string, (release: string) => IntegrationRegistration] +> = Object.freeze([ + ['datadome', createDataDomeIntegrationRegistration] as const, + ['didomi', createDidomiIntegrationRegistration] as const, + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration] as const, + ['lockr', createLockrIntegrationRegistration] as const, + ['osano', createOsanoIntegrationRegistration] as const, + ['permutive', createPermutiveIntegrationRegistration] as const, + ['sourcepoint', createSourcepointIntegrationRegistration] as const, + ['testlight', createTestlightIntegrationRegistration] as const, +]); +const configFor = (id: string): unknown => { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/sdk' }); + if (id === 'sourcepoint') return Object.freeze({ rewriteSdk: true }); + return undefined; +}; + +describe('remaining integration lifecycle modules', () => { + it('activates a maximal manifest once in order and disposes it in exact reverse order', async () => { + const order: string[] = []; + const ids = Object.freeze(registrations.map(([id]) => id)); + const interfaces = Object.freeze( + Object.fromEntries( + ids.map((id) => [ + id, + Object.freeze({ + activate: (config: unknown) => { + expect(config).toEqual(configFor(id)); + order.push(`activate:${id}`); + return () => order.push(`dispose:${id}`); + }, + start: () => order.push(`start:${id}`), + }), + ]) + ) + ); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true })), + }, + releaseId: RELEASE_ID, + knownIntegrationIds: ids, + startedAtMs: 0, + now: () => 0, + getBindings: (id) => ({ config: configFor(id), interfaces }), + }); + for (const [, createRegistration] of registrations) { + expect(registry.register(createRegistration(RELEASE_ID))).toBe(true); + } + + const result = await registry.install({ + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual([ + 'core', + ...ids.map((id) => `activate:${id}`), + 'publish', + ...ids.map((id) => `start:${id}`), + 'drain', + ]); + if (result.state === 'kernel') result.dispose(); + expect(order.slice(-ids.length)).toEqual([...ids].reverse().map((id) => `dispose:${id}`)); + }); + + it.each(registrations)( + '%s runs alone without cross-integration authority', + async (id, create) => { + const activate = vi.fn(() => vi.fn()); + const start = vi.fn(); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id, required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze([id]), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config: configFor(id), + interfaces: Object.freeze({ [id]: Object.freeze({ activate, start }) }), + }), + }); + registry.register(create(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'kernel' }); + expect(activate).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledOnce(); + } + ); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts new file mode 100644 index 000000000..d44677b10 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createLockrRuntime } from '../../../src/integrations/lockr/module'; + +describe('transactional Lockr integration module', () => { + afterEach(() => vi.useRealTimers()); + + it('rewrites a later initialized SDK once and compare-restores its host', async () => { + vi.useFakeTimers(); + const state: { sdk?: { host: string } } = {}; + const resetGuard = vi.fn(); + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => state.sdk, + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + await vi.advanceTimersByTimeAsync(49); + const sdk = { host: 'https://identity.loc.kr' }; + state.sdk = sdk; + await vi.advanceTimersByTimeAsync(1); + + expect(sdk.host).toBe('https://news.example/integrations/lockr/api'); + sdk.host = 'https://publisher.example/replacement'; + release(); + expect(sdk.host).toBe('https://publisher.example/replacement'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); + + it('stops after 50 readiness checks and owns no later timer', async () => { + vi.useFakeTimers(); + const timedOut = vi.fn(); + const setTimeout = vi.fn((callback: () => void, delay: number) => + window.setTimeout(callback, delay) + ); + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard: vi.fn(), + setTimeout, + started: vi.fn(), + timedOut, + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + + await vi.advanceTimersByTimeAsync(2_500); + + expect(setTimeout).toHaveBeenCalledTimes(49); + expect(timedOut).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + release(); + }); + + it('cancels readiness work on disposal before the SDK appears', async () => { + vi.useFakeTimers(); + const sdk = { host: 'https://identity.loc.kr' }; + let available = false; + const runtime = createLockrRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (available ? sdk : undefined), + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard: vi.fn(), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + release(); + available = true; + + await vi.runAllTimersAsync(); + + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts index 811be1f38..d7f2892ae 100644 --- a/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/osano/index.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + disposeOsanoConsentMirror, initializeOsanoConsentMirror, mirrorOsanoConsent, - resetOsanoConsentMirrorForTest, } from '../../../src/integrations/osano'; type TestWindow = Window & { @@ -80,7 +80,7 @@ function setOsanoStub(): Record void> { describe('integrations/osano consent mirror', () => { beforeEach(() => { - resetOsanoConsentMirrorForTest(); + disposeOsanoConsentMirror(); clearAllCookies(); delete (window as TestWindow).Osano; delete (window as TestWindow).__uspapi; @@ -90,7 +90,7 @@ describe('integrations/osano consent mirror', () => { afterEach(() => { vi.useRealTimers(); - resetOsanoConsentMirrorForTest(); + disposeOsanoConsentMirror(); clearAllCookies(); delete (window as TestWindow).Osano; delete (window as TestWindow).__uspapi; @@ -436,4 +436,33 @@ describe('integrations/osano consent mirror', () => { expect(listeners['osano-cm-consent-saved']).toEqual(expect.any(Function)); expect(getCookie('us_privacy')).toBe('1YN-'); }); + + it('cancels in-flight API timeouts and makes late callbacks inert on disposal', async () => { + vi.useFakeTimers(); + const callbacks = setControlledUspApi(); + const pending = mirrorOsanoConsent(); + + expect(vi.getTimerCount()).toBe(1); + disposeOsanoConsentMirror(); + expect(vi.getTimerCount()).toBe(0); + await expect(pending).resolves.toBe(false); + + callbacks[0]?.({ uspString: 'late-consent' }, true); + await Promise.resolve(); + expect(getCookie('us_privacy')).toBeUndefined(); + expect(getCookie(MARKER_COOKIE)).toBeUndefined(); + }); + + it('does not retain Osano listeners when the vendor exposes no removal API', async () => { + vi.useFakeTimers(); + const addEventListener = vi.fn(); + (window as TestWindow).Osano = { cm: { addEventListener } }; + + initializeOsanoConsentMirror(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(addEventListener).not.toHaveBeenCalled(); + disposeOsanoConsentMirror(); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts b/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts new file mode 100644 index 000000000..0c1dad220 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/osano/module.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createOsanoRuntime } from '../../../src/integrations/osano/module'; + +describe('transactional Osano integration module', () => { + it('keeps activation reversible and starts the consent mirror once after commit', () => { + const initialize = vi.fn(); + const reset = vi.fn(); + const runtime = createOsanoRuntime({ initialize, reset }); + + const release = runtime.activate(undefined); + + expect(initialize).not.toHaveBeenCalled(); + runtime.start(undefined); + runtime.start(undefined); + expect(initialize).toHaveBeenCalledOnce(); + release(); + release(); + expect(reset).toHaveBeenCalledOnce(); + }); + + it('resets partial consent ownership when startup throws', () => { + const reset = vi.fn(); + const runtime = createOsanoRuntime({ + initialize: () => { + throw new Error('listener failed'); + }, + reset, + }); + const release = runtime.activate(undefined); + + expect(() => runtime.start(undefined)).toThrow('listener failed'); + release(); + + expect(reset).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts new file mode 100644 index 000000000..3408ac40d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createPermutiveRuntime } from '../../../src/integrations/permutive/module'; + +describe('transactional Permutive integration module', () => { + afterEach(() => vi.useRealTimers()); + + it('registers one disposable auction-context contributor during activation', () => { + const order: string[] = []; + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + getSegments: () => ['11', '22'], + installGuard: () => order.push('guard:install'), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: (candidate) => { + contributor = candidate; + order.push('context:register'); + return () => order.push('context:release'); + }, + resetGuard: () => order.push('guard:reset'), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + + const release = runtime.activate(undefined); + + expect(contributor?.()).toEqual({ permutive_segments: ['11', '22'] }); + expect(order).toEqual(['guard:install', 'context:register']); + release(); + release(); + expect(order).toEqual(['guard:install', 'context:register', 'context:release', 'guard:reset']); + }); + + it('bounds a context-service segment snapshot even when an injected reader overproduces', () => { + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = createPermutiveRuntime({ + getSegments: () => Array.from({ length: 101 }, (_, index) => `${index}`), + installGuard: vi.fn(), + registerContext: (candidate) => { + contributor = candidate; + return vi.fn(); + }, + resetGuard: vi.fn(), + }); + + const release = runtime.activate(undefined); + const snapshot = contributor?.() as { readonly permutive_segments?: readonly string[] }; + + expect(snapshot.permutive_segments).toHaveLength(100); + expect(Object.isFrozen(snapshot.permutive_segments)).toBe(true); + release(); + }); + + it('rewrites a later SDK config and compare-restores every owned field', async () => { + vi.useFakeTimers(); + const config = { + apiHost: 'api.permutive.com', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }; + let available = false; + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => (available ? { config } : undefined), + getSegments: () => [], + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: () => vi.fn(), + resetGuard: vi.fn(), + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + available = true; + await vi.advanceTimersByTimeAsync(50); + + expect(config).toEqual({ + apiHost: 'news.example/integrations/permutive/api', + apiProtocol: 'https', + cdnBaseUrl: 'news.example/integrations/permutive/cdn', + cdnProtocol: 'https', + secureSignalsApiHost: 'news.example/integrations/permutive/secure-signal', + segmentSyncApiHost: 'news.example/integrations/permutive/sync', + }); + config.apiHost = 'publisher.example/replacement'; + release(); + expect(config).toEqual({ + apiHost: 'publisher.example/replacement', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.permutive.com', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.permutive.com', + segmentSyncApiHost: 'sync.permutive.com', + }); + }); + + it('rolls back the guard when context registration is refused', () => { + const resetGuard = vi.fn(); + const runtime = createPermutiveRuntime({ + clearTimeout: (timer) => window.clearTimeout(timer), + getSdk: () => undefined, + getSegments: () => [], + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + registerContext: () => undefined, + resetGuard, + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + started: vi.fn(), + timedOut: vi.fn(), + }); + + expect(() => runtime.activate(undefined)).toThrowError('Permutive context registration failed'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts new file mode 100644 index 000000000..8be84e35d --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/module.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createSourcepointIntegrationRegistration, + createSourcepointRuntime, +} from '../../../src/integrations/sourcepoint/module'; +import { createIntegrationRegistry } from '../../../src/kernel/integration_registry'; + +const RELEASE_ID = 'a'.repeat(64); + +describe('transactional Sourcepoint integration module', () => { + it.each([true, false])( + 'owns the optional SDK guard and consent mirror when rewriteSdk=%s', + (rewriteSdk) => { + const order: string[] = []; + const runtime = createSourcepointRuntime({ + initializeConsentMirror: () => order.push('start:consent'), + installGuard: () => order.push('activate:guard'), + resetConsentMirror: () => order.push('dispose:consent'), + resetGuard: () => order.push('dispose:guard'), + }); + const config = Object.freeze({ rewriteSdk }); + + const release = runtime.activate(config); + runtime.start(config); + release(); + release(); + + expect(order).toEqual( + rewriteSdk + ? ['activate:guard', 'start:consent', 'dispose:consent', 'dispose:guard'] + : ['start:consent', 'dispose:consent'] + ); + } + ); + + it.each([ + ['missing', undefined], + ['mutable', { rewriteSdk: true }], + ['wrong type', Object.freeze({ rewriteSdk: 'yes' })], + ['extra', Object.freeze({ rewriteSdk: true, legacy: true })], + ])('rejects %s boot config before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const registry = createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'sourcepoint', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['sourcepoint']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ + sourcepoint: Object.freeze({ activate, start: vi.fn() }), + }), + }), + }); + registry.register(createSourcepointIntegrationRegistration(RELEASE_ID)); + + await expect( + registry.install({ activateCore: vi.fn(), publish: vi.fn(), drainPreload: vi.fn() }) + ).resolves.toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts new file mode 100644 index 000000000..a18e6e199 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestlightRuntime } from '../../../src/integrations/testlight/module'; + +describe('transactional Testlight integration module', () => { + it('bridges preexisting and later callbacks once while isolating invalid and throwing work', () => { + const calls: string[] = []; + const first = () => calls.push('first'); + const throwing = () => { + calls.push('throwing'); + throw new Error('publisher callback failed'); + }; + const second = () => calls.push('second'); + const beforeCommit = () => calls.push('before-commit'); + const afterCommit = () => calls.push('after-commit'); + const original = [first, 'invalid', throwing, second]; + const target = { testlight: { publisher: true, que: original } }; + const enqueue = vi.fn((callback: () => void) => callback()); + const runtime = createTestlightRuntime({ enqueue, started: vi.fn(), target }); + + const release = runtime.activate(undefined); + target.testlight.que.push(beforeCommit); + expect(calls).toEqual([]); + + runtime.start(undefined); + target.testlight.que.push(afterCommit); + + expect(calls).toEqual(['first', 'throwing', 'second', 'before-commit', 'after-commit']); + expect(enqueue).toHaveBeenCalledTimes(5); + release(); + release(); + expect(target.testlight).toEqual({ publisher: true, que: original }); + }); + + it('returns callbacks added during activation to the publisher queue on rollback', () => { + const original = [vi.fn()]; + const later = vi.fn(); + const target = { testlight: { que: original } }; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + + const release = runtime.activate(undefined); + target.testlight.que.push(later); + release(); + + expect(target.testlight.que).toBe(original); + expect(original).toEqual([expect.any(Function), later]); + }); + + it('does not overwrite a publisher queue replacement during disposal', () => { + const target = { testlight: { que: [] as unknown[] } }; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + const release = runtime.activate(undefined); + const replacement: unknown[] = []; + target.testlight.que = replacement; + + release(); + + expect(target.testlight.que).toBe(replacement); + }); + + it('preserves publisher fields added to a runtime-created global', () => { + const target: { testlight?: { publisher?: boolean; que?: unknown[] } } = {}; + const runtime = createTestlightRuntime({ + enqueue: vi.fn(), + started: vi.fn(), + target, + }); + const release = runtime.activate(undefined); + if (!target.testlight) throw new Error('should create the Testlight global'); + target.testlight.publisher = true; + + release(); + + expect(target.testlight).toEqual({ publisher: true }); + }); + + it('snapshots queue data without invoking a publisher iterator', () => { + const callback = vi.fn(); + const original = [callback]; + Object.defineProperty(original, Symbol.iterator, { + configurable: true, + value: () => { + throw new Error('publisher iterator must remain inert'); + }, + }); + const target = { testlight: { que: original } }; + const enqueue = vi.fn((candidate: () => void) => candidate()); + const runtime = createTestlightRuntime({ enqueue, started: vi.fn(), target }); + + const release = runtime.activate(undefined); + expect(() => runtime.start(undefined)).not.toThrow(); + + expect(callback).toHaveBeenCalledOnce(); + release(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts new file mode 100644 index 000000000..97d93a0b6 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createIntegrationRegistry, + type IntegrationInstallCallbacks, +} from '../../src/kernel/integration_registry'; +import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; + +const RELEASE_ID = 'a'.repeat(64); + +function callbacks(order: string[]): IntegrationInstallCallbacks { + return { + activateCore: () => order.push('core'), + publish: () => order.push('publish'), + drainPreload: () => order.push('drain'), + }; +} + +function registry(config: unknown, runtime: unknown) { + return createIntegrationRegistry({ + manifest: { + version: 1, + releaseId: RELEASE_ID, + integrations: [{ id: 'example', required: true }], + }, + releaseId: RELEASE_ID, + knownIntegrationIds: Object.freeze(['example']), + startedAtMs: 0, + now: () => 0, + getBindings: () => ({ + config, + interfaces: Object.freeze({ example: runtime }), + }), + }); +} + +describe('shared integration lifecycle module', () => { + it('prepares inertly, activates reversibly, and starts only after publication', async () => { + const order: string[] = []; + const config = Object.freeze({ nested: Object.freeze({ enabled: true }) }); + const release = vi.fn(() => order.push('release')); + const activate = vi.fn((received: unknown) => { + expect(received).toBe(config); + order.push('activate'); + return release; + }); + const start = vi.fn((received: unknown) => { + expect(received).toBe(config); + order.push('start'); + }); + const runtime = Object.freeze({ activate, start }); + const owner = registry(config, runtime); + owner.register(createLifecycleIntegrationRegistration('example', RELEASE_ID)); + + const result = await owner.install(callbacks(order)); + + expect(result).toMatchObject({ state: 'kernel' }); + expect(order).toEqual(['core', 'activate', 'publish', 'start', 'drain']); + if (result.state === 'kernel') result.dispose(); + expect(release).toHaveBeenCalledOnce(); + }); + + it.each([ + ['mutable root', { enabled: true }], + ['mutable nested value', Object.freeze({ nested: { enabled: true } })], + ['accessor', Object.freeze(Object.defineProperty({}, 'enabled', { get: () => true }))], + ['function', Object.freeze(() => undefined)], + ])('rejects %s configuration before activation', async (_name, config) => { + const activate = vi.fn(() => vi.fn()); + const owner = registry(config, Object.freeze({ activate, start: vi.fn() })); + owner.register(createLifecycleIntegrationRegistration('example', RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); + + it('rejects extra runtime authority and unwinds activation when startup peers fail', async () => { + const activate = vi.fn(() => vi.fn()); + const owner = registry( + Object.freeze({}), + Object.freeze({ activate, start: vi.fn(), publish: vi.fn() }) + ); + owner.register(createLifecycleIntegrationRegistration('example', RELEASE_ID)); + + await expect(owner.install(callbacks([]))).resolves.toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(activate).not.toHaveBeenCalled(); + }); +}); From 079f07f7f39f001188820ea1d82ba88d02ddd634 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:40:52 -0700 Subject: [PATCH 373/494] Compose remaining integration runtimes --- .../lib/src/composition/browser.ts | 94 ++++++++++++++++++- .../lib/test/composition/browser.test.ts | 87 +++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index c8dd23d22..8d8fda9c1 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -46,6 +46,9 @@ import { installClickGuard } from '../integrations/creative/click'; import { installDynamicIframeProxy } from '../integrations/creative/iframe'; import { installDynamicImageProxy } from '../integrations/creative/image'; import { createCreativeStartup } from '../integrations/creative/startup'; +import { createDataDomeRuntime } from '../integrations/datadome/module'; +import { createDidomiRuntime } from '../integrations/didomi/module'; +import { createGoogleTagManagerRuntime } from '../integrations/google_tag_manager/module'; import { publishGptWinner, startGptSlotOperation, @@ -69,6 +72,11 @@ import { type PrebidSelectionCoordinator, } from '../integrations/prebid/module'; import { createPrebidStartup } from '../integrations/prebid/startup'; +import { createLockrRuntime } from '../integrations/lockr/module'; +import { createOsanoRuntime } from '../integrations/osano/module'; +import { createPermutiveRuntime } from '../integrations/permutive/module'; +import { createSourcepointRuntime } from '../integrations/sourcepoint/module'; +import { createTestlightRuntime } from '../integrations/testlight/module'; import { createBrowserNavigationIdentityIssuer } from '../kernel/identity'; import { createDiagnosticsBus, @@ -83,7 +91,12 @@ import type { import { createRuntimeSession } from '../kernel/sessions'; import type { CoreActivationContext } from '../kernel/integration_registry'; import { createRuntime, type Runtime, type RuntimeOptions } from '../kernel/runtime'; -import { createAuctionContextRegistry, type AuctionContextRegistry } from '../services/context'; +import { + createAuctionContextRegistry, + type AuctionContextContributor, + type AuctionContextRegistry, + type ContextContributorOwner, +} from '../services/context'; import { createAuctionBatchService, type AuctionBatchFetcher, @@ -207,7 +220,9 @@ interface AcceptedBrowserBoot { readonly cachePolicy?: unknown; readonly creative: Readonly; readonly diagnostics: Readonly; + readonly didomi?: unknown; readonly manifest: Readonly; + readonly sourcepoint?: unknown; } interface PreparedBrowserServices { @@ -225,6 +240,38 @@ function projectionSlots(projection: object): readonly string[] { return Object.freeze(accepted.auction.results.map(({ slot }) => slot)); } +function registerScopedContextContributor( + registry: AuctionContextRegistry, + runtimeOwner: RuntimeSession, + integrationId: string, + contributor: AuctionContextContributor +): (() => void) | undefined { + let active = true; + let releaseRegistration: (() => void) | undefined; + const owner: ContextContributorOwner = Object.freeze({ + generation: Object.freeze({}), + isCurrent: () => active && runtimeOwner.isCurrent(), + onDispose: (kind: string, callback: () => void) => { + if (kind !== 'auction-context-contributor' || !active || releaseRegistration) { + throw new Error('Auction context contributor disposer is unavailable'); + } + releaseRegistration = callback; + }, + }); + if (!registry.register(integrationId, contributor, owner)) { + active = false; + releaseRegistration?.(); + return undefined; + } + return (): void => { + if (!active) return; + active = false; + const release = releaseRegistration; + releaseRegistration = undefined; + release?.(); + }; +} + /** * Construct concrete browser dependencies in one place. * @@ -301,6 +348,7 @@ export function createTestBrowserRuntimeComposition( let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; let gptDiagnosticsRuntime: GptDiagnosticsRuntime | undefined; let renderTrace: RenderTraceRuntimeOwner | undefined; + let acceptedBrowserBoot: AcceptedBrowserBoot | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { if ( observation['kind'] !== 'render_attempt' || @@ -370,6 +418,10 @@ export function createTestBrowserRuntimeComposition( }, start: startGpt, }); + const gptIntegrationRuntime = Object.freeze({ + activate: gptRuntime.activate, + start: gptRuntime.start, + }); let runtimeSession: RuntimeSession | undefined; let prebidCoordinator: PrebidSelectionCoordinator | undefined; const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); @@ -451,6 +503,8 @@ export function createTestBrowserRuntimeComposition( } if (id === 'creative' && config === undefined) config = creativeBoot; if (id === 'gpt_diagnostics' && config === undefined) config = diagnosticsBoot?.gpt; + if (id === 'didomi' && config === undefined) config = acceptedBrowserBoot?.didomi; + if (id === 'sourcepoint' && config === undefined) config = acceptedBrowserBoot?.sourcepoint; const interfaces = runtimeSession?.interfaces; if (!interfaces) throw new Error(`Integration interfaces are unavailable for ${id}`); return Object.freeze({ @@ -460,6 +514,32 @@ export function createTestBrowserRuntimeComposition( }; let preparedBrowserServices: PreparedBrowserServices | undefined; let auctionContextRegistry: AuctionContextRegistry | undefined; + const dataDomeRuntime = createDataDomeRuntime(); + const didomiRuntime = createDidomiRuntime(); + const googleTagManagerRuntime = createGoogleTagManagerRuntime(); + const lockrRuntime = createLockrRuntime(); + const osanoRuntime = createOsanoRuntime(); + const permutiveRuntime = createPermutiveRuntime({ + registerContext: (contributor) => { + const registry = auctionContextRegistry; + const owner = runtimeSession; + return registry && owner + ? registerScopedContextContributor(registry, owner, 'permutive', contributor) + : undefined; + }, + }); + const sourcepointRuntime = createSourcepointRuntime(); + const testlightRuntime = createTestlightRuntime({ + enqueue: (callback) => { + const queue = (runtimeOptions.target as { readonly que?: unknown }).que; + if (!Array.isArray(queue) || typeof queue.push !== 'function') { + throw new Error('Testlight TSJS queue is unavailable'); + } + queue.push(callback); + }, + started: () => log.info('Testlight integration initialized'), + target: window as typeof window & { testlight?: { que?: unknown[] } }, + }); let auctionBatchService: AuctionBatchService | undefined; let projectionParser: ((candidate: unknown) => object | undefined) | undefined; const frozenSlotResult = (result: Record): Readonly> => @@ -649,6 +729,7 @@ export function createTestBrowserRuntimeComposition( }, prepareOwner: (context) => { const boot = context.boot as unknown as AcceptedBrowserBoot; + acceptedBrowserBoot = boot; creativeBoot = boot.creative; diagnosticsBoot = boot.diagnostics; const cachePolicy = @@ -889,12 +970,20 @@ export function createTestBrowserRuntimeComposition( interfaces: Object.freeze({ adapters: composition.adapters, creative: creativeRuntime, + datadome: dataDomeRuntime, diagnostics: Object.freeze({ subscribe: preparedDiagnosticsBus.subscribe }), + didomi: didomiRuntime, + google_tag_manager: googleTagManagerRuntime, ...(preparedGptDiagnosticsRuntime ? { gpt_diagnostics: preparedGptDiagnosticsRuntime } : {}), - gpt: gptRuntime, + gpt: gptIntegrationRuntime, + lockr: lockrRuntime, + osano: osanoRuntime, + permutive: permutiveRuntime, prebid: prebidRuntime, + sourcepoint: sourcepointRuntime, + testlight: testlightRuntime, ...services, }), onNavigationDispose: (navigationGeneration) => @@ -917,6 +1006,7 @@ export function createTestBrowserRuntimeComposition( auctionBatchService = undefined; auctionContextRegistry = undefined; projectionParser = undefined; + acceptedBrowserBoot = undefined; creativeBoot = undefined; diagnosticsBoot = undefined; } diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index c5af73d33..344444e8b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -31,10 +31,18 @@ import { import { log as localLog } from '../../src/core/log'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; import { isGuardInstalled, resetGuardState } from '../../src/integrations/gpt/script_guard'; import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; import { @@ -990,6 +998,85 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + it('owns every remaining integration in one maximal composed transaction', async () => { + vi.useFakeTimers(); + const releaseId = 'a'.repeat(64); + const target = {}; + const members = Object.freeze([ + ['datadome', createDataDomeIntegrationRegistration] as const, + ['didomi', createDidomiIntegrationRegistration] as const, + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration] as const, + ['lockr', createLockrIntegrationRegistration] as const, + ['osano', createOsanoIntegrationRegistration] as const, + ['permutive', createPermutiveIntegrationRegistration] as const, + ['sourcepoint', createSourcepointIntegrationRegistration] as const, + ['testlight', createTestlightIntegrationRegistration] as const, + ]); + const ids = Object.freeze(members.map(([id]) => id)); + const configFor = (id: string): unknown => { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + if (id === 'sourcepoint') return Object.freeze({ rewriteSdk: true }); + return undefined; + }; + const appendChildBefore = Element.prototype.appendChild; + const insertBeforeBefore = Element.prototype.insertBefore; + const didomiBefore = Object.getOwnPropertyDescriptor(window, 'didomiConfig'); + const testlightBefore = Object.getOwnPropertyDescriptor(window, 'testlight'); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: ids.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: ids, + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: (id) => ({ config: configFor(id), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + for (const [, createRegistration] of members) { + expect(composition.runtime.registerIntegration(createRegistration(releaseId))).toBe(true); + } + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(composition.auctionContextRegistryForTest()?.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['permutive'], + }); + expect(composition.runtimeSessionForTest()?.interfaces).toMatchObject( + Object.fromEntries(ids.map((id) => [id, expect.any(Object)])) + ); + expect(vi.getTimerCount()).toBeGreaterThan(0); + + composition.runtime.dispose(); + composition.runtime.dispose(); + expect(vi.getTimerCount()).toBe(0); + expect(Element.prototype.appendChild).toBe(appendChildBefore); + expect(Element.prototype.insertBefore).toBe(insertBeforeBefore); + expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); + expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); + }); + it('injects the exact creative boot into reversible activation and post-commit startup', async () => { const releaseId = 'a'.repeat(64); const creative = Object.freeze({ From cd13e230510cabff3558893330729fdf16bea112 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:42:02 -0700 Subject: [PATCH 374/494] Build the Prebid refresh policy boundary --- .../lib/build-prebid-external.mjs | 11 +- .../lib/src/adapters/googletag.ts | 63 ++- .../lib/src/adapters/prebid.ts | 4 + .../lib/src/composition/browser.ts | 40 +- .../lib/src/integrations/gpt/startup.ts | 49 +- .../lib/src/integrations/prebid/module.ts | 453 ++++++++++++++++++ .../lib/src/integrations/prebid/startup.ts | 55 ++- .../lib/test/adapters/googletag.test.ts | 74 +++ .../lib/test/adapters/prebid.test.ts | 4 + .../lib/test/composition/browser.test.ts | 14 +- .../lib/test/integrations/gpt/startup.test.ts | 68 ++- .../test/integrations/prebid/module.test.ts | 426 +++++++++++++++- .../test/integrations/prebid/startup.test.ts | 123 +++++ .../test/prebid-artifact-integration.test.mjs | 126 ++++- 14 files changed, 1488 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 7f972da5a..9d0b14aa4 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -303,16 +303,19 @@ function renderExternalWrapper(bundleCode, stamp) { 'function __tsData(value,key){try{var descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&Object.prototype.hasOwnProperty.call(descriptor,"value")&&descriptor.enumerable===true&&descriptor.writable===false&&descriptor.configurable===false?descriptor.value:__tsMissing;}catch(_){return __tsMissing;}}', 'function __tsRecord(value,keys){if(!value||typeof value!=="object"||Object.getPrototypeOf(value)!==Object.prototype||!Object.isFrozen(value))return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==keys.length)return false;for(var i=0;imax)return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==value.length+1)return false;for(var i=0;i256||(previous!==undefined&&previous>=current))return false;previous=current;}return true;}', + 'function __tsString(value,max,lowercase){if(typeof value!=="string"||value.length===0||(lowercase&&value!==value.toLowerCase()))return false;var bytes=0;for(var i=0;i=55296&&code<=56319){var next=value.charCodeAt(i+1);if(next<56320||next>57343)return false;bytes+=4;i+=1;}else if(code>=56320&&code<=57343)return false;else if(code<=127)bytes+=1;else if(code<=2047)bytes+=2;else bytes+=3;if(bytes>max)return false;}return true;}', + 'function __tsSortedStrings(value,max,maxBytes,lowercase){if(!__tsArray(value,max))return false;var previous;for(var i=0;i=current))return false;previous=current;}return true;}', 'function __tsContains(values,expected){for(var i=0;i=identity)||!__tsContains(bidders,code)||!__tsContains(modules,stem))return false;previous=identity;}previous="";for(var j=0;j=name)||!__tsContains(modules,name)||!__tsSortedStrings(configs,64)||!__tsSortedStrings(sources,64))return false;for(var k=0;k=identity)||!__tsContains(bidders,code)||!__tsContains(modules,stem))return false;previous=identity;}previous="";for(var j=0;j=name)||!__tsContains(modules,name)||!__tsSortedStrings(configs,64,128,false)||!__tsSortedStrings(sources,64,256,true))return false;previous=name;}return true;}catch(_){return false;}}', 'function __tsEqual(left,right){if(left===right)return true;if(!left||!right||typeof left!=="object"||typeof right!=="object")return false;var leftKeys=Reflect.ownKeys(left);var rightKeys=Reflect.ownKeys(right);if(leftKeys.length!==rightKeys.length)return false;for(var i=0;i + | Readonly<{ + action: 'defer'; + slots: readonly object[]; + completion: PromiseLike; + admission?: GoogletagPublisherCallAdmission; + }> | Readonly<{ action: 'suppress' }>; } @@ -139,10 +145,12 @@ export interface GoogletagPublisherDisplayCall { export interface GoogletagPublisherRefreshCall { readonly requestedSlots: readonly object[] | undefined; readonly slots: readonly object[]; + readonly options?: unknown; } /** The small GPT surface exposed to an accepted operation. */ export interface GoogletagFacade { + adUnitPath?(slot: object): unknown; bindingToken(): object; clearTargeting(slot: object, key?: string): unknown; display(slot: string | object): unknown; @@ -533,6 +541,7 @@ function createFacade( } }; return Object.freeze({ + adUnitPath: (slot: object): unknown => call(slot, 'getAdUnitPath', []), bindingToken: (): object => bindingToken, clearTargeting: (slot: object, key?: string): unknown => call(slot, 'clearTargeting', key === undefined ? [] : [key]), @@ -2105,6 +2114,7 @@ export function createBrowserGoogletagAdapter( return undefined; } }; + const deferredRefreshes = new Set<() => void>(); const restorers: Array<() => void> = []; const install = ( external: object, @@ -2188,7 +2198,11 @@ export function createBrowserGoogletagAdapter( let decision: ReturnType>; try { decision = refreshObserver( - Object.freeze({ requestedSlots: requested, slots: effective }) + Object.freeze({ + requestedSlots: requested, + slots: effective, + options: arguments_[1], + }) ); } catch { // Observer failure must leave the publisher call native. @@ -2212,6 +2226,51 @@ export function createBrowserGoogletagAdapter( rollbackAdmission(admission); return Reflect.apply(original, receiver, arguments_); } + if (decision?.action === 'defer') { + const replacement = objectSlots(decision.slots); + const completion = safeMember(decision, 'completion'); + const then = + (typeof completion === 'object' && completion !== null) || + typeof completion === 'function' + ? safeMember(completion as object, 'then') + : undefined; + if (!replacement || typeof then !== 'function') { + rollbackAdmission(admission); + return Reflect.apply(original, receiver, arguments_); + } + let forwarded = false; + const forward = (): void => { + if (forwarded) return; + forwarded = true; + try { + deleteSetValue(deferredRefreshes, forward); + } catch { + // The exact-once latch remains authoritative under hostile bookkeeping. + } + try { + callWithAdmission(original, receiver, [replacement, arguments_[1]], admission); + } catch { + // A deferred native throw has no synchronous publisher frame to receive it. + } + }; + try { + addSetValue(deferredRefreshes, forward); + Promise.resolve(completion).then(forward, forward); + } catch { + try { + deleteSetValue(deferredRefreshes, forward); + } catch { + // Synchronous fail-open still owns the only native forward. + } + return callWithAdmission( + original, + receiver, + [replacement, arguments_[1]], + admission + ); + } + return undefined; + } return callWithAdmission(original, receiver, arguments_, admission); } } @@ -2247,6 +2306,8 @@ export function createBrowserGoogletagAdapter( } catch { // Exact wrapper restoration still runs when bookkeeping is hostile. } + const deferred = setValueSnapshot(deferredRefreshes); + for (let index = 0; index < deferred.length; index += 1) deferred[index]?.(); for (let index = restorers.length - 1; index >= 0; index -= 1) restorers[index]?.(); }; registerAdapterEffect(release); diff --git a/crates/trusted-server-js/lib/src/adapters/prebid.ts b/crates/trusted-server-js/lib/src/adapters/prebid.ts index 26b7dfe1c..5b9863d5a 100644 --- a/crates/trusted-server-js/lib/src/adapters/prebid.ts +++ b/crates/trusted-server-js/lib/src/adapters/prebid.ts @@ -132,6 +132,7 @@ export interface PrebidFacade { ): () => void; renderAd(targetDocument: object, adId: string): unknown; requestBids(options: object): unknown; + setTargetingForGpt(adUnitCodes: readonly string[]): unknown; subscribe( eventType: string, listener: (event: unknown, prebid: Readonly) => void @@ -537,6 +538,7 @@ const REQUIRED_API_METHODS = [ 'registerBidAdapter', 'renderAd', 'requestBids', + 'setTargetingForGPTAsync', ] as const; function commandQueue(binding: object): CommandQueue | undefined { @@ -1083,6 +1085,8 @@ export function createBrowserPrebidAdapter( callBound(binding, 'renderAd', [targetDocument, adId], isOperationCurrent), requestBids: (options: object): unknown => callBound(binding, 'requestBids', [options], isOperationCurrent), + setTargetingForGpt: (adUnitCodes: readonly string[]): unknown => + callBound(binding, 'setTargetingForGPTAsync', [[...adUnitCodes]], isOperationCurrent), subscribe: ( eventType: string, listener: (event: unknown, prebid: Readonly) => void diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 8d8fda9c1..20f82162e 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -26,7 +26,11 @@ import type { CreativeBootV1, DiagnosticsBootV1, } from '../core/types'; -import { createRenderTrace, type RenderTraceRuntimeOwner } from '../core/trace'; +import { + createRenderTrace, + isEffectivelyVisible, + type RenderTraceRuntimeOwner, +} from '../core/trace'; import { parseBidRenderSourceV1, parseBrowserAuctionProjectionV1, @@ -354,7 +358,8 @@ export function createTestBrowserRuntimeComposition( observation['kind'] !== 'render_attempt' || typeof observation['slotId'] !== 'string' || (observation['path'] !== 'auction' && observation['path'] !== 'ssat') || - typeof observation['rendered'] !== 'boolean' + typeof observation['rendered'] !== 'boolean' || + typeof observation['injected'] !== 'boolean' ) { return; } @@ -373,10 +378,37 @@ export function createTestBrowserRuntimeComposition( const servedFrom = observation['servedFrom']; if (servedFrom !== undefined && servedFrom !== 'inline' && servedFrom !== 'pbs-cache') return; try { + const slotId = observation['slotId']; + const slot = browserServices?.slots.resolveRegisteredSlot(slotId); + const identifiers = slot + ? new Set([slot.registeredSlotId, ...slot.domAliases]) + : new Set([slotId]); + const elements = new Set(); + if (typeof document !== 'undefined') { + for (const identifier of identifiers) { + const element = document.getElementById(identifier); + if (element instanceof HTMLElement) elements.add(element); + } + } + const element = elements.size === 1 ? [...elements][0] : undefined; + const optionalString = (name: 'adId' | 'bidId' | 'creativeId'): string | undefined => { + const value = observation[name]; + return typeof value === 'string' && value !== '' ? value : undefined; + }; + const adId = optionalString('adId'); + const bidId = optionalString('bidId'); + const creativeId = optionalString('creativeId'); renderTrace?.record({ - slotId: observation['slotId'], + slotId, path: observation['path'], rendered: observation['rendered'], + injected: observation['injected'], + ...(element === undefined + ? {} + : { elementId: element.id, visible: isEffectivelyVisible(element) }), + ...(adId === undefined ? {} : { adId }), + ...(bidId === undefined ? {} : { bidId }), + ...(creativeId === undefined ? {} : { creativeId }), ...(servedFrom === undefined ? {} : { servedFrom }), }); } catch { @@ -742,7 +774,9 @@ export function createTestBrowserRuntimeComposition( ); if (!initialProjection) throw new Error('Accepted boot projection is unavailable'); const preparedRenderTrace = createRenderTrace({ + ...(typeof document === 'undefined' ? {} : { document }), onSubscriberError: (error) => log.warn('render diagnostics: subscriber failed', error), + overlayEnabled: boot.diagnostics.renderTraceOverlay, }); const preparedDiagnosticsBus = createDiagnosticsBus({ manifest: boot.manifest, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts index 0a63d88ad..ed3399e1c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/startup.ts @@ -19,9 +19,17 @@ type GptPublisherSlotBoundary = Pick< export interface GptStartup { readonly activate: () => () => void; + readonly installRefreshPolicy: (policy: GptRefreshPolicy) => (() => void) | undefined; readonly start: (config: unknown) => void; } +/** One optional Prebid policy composed into the sole publisher refresh observer. */ +export interface GptRefreshPolicy { + readonly prepare: ( + call: Readonly + ) => PromiseLike | undefined; +} + export interface GptStartupOptions { readonly googletag: Pick; readonly slots: () => GptPublisherSlotBoundary; @@ -30,6 +38,7 @@ export interface GptStartupOptions { /** Join the sole GPT interception boundary to runtime-owned slot handoff state. */ export function createGptStartup(options: GptStartupOptions): GptStartup { + let refreshPolicy: GptRefreshPolicy | undefined; return Object.freeze({ activate: (): (() => void) => { const slots = options.slots(); @@ -44,11 +53,47 @@ export function createGptStartup(options: GptStartupOptions): GptStartup { }, display: (call: Readonly) => slots.preparePublisherDisplay(call), - refresh: (call: Readonly) => - slots.preparePublisherRefresh(call), + refresh: (call: Readonly) => { + const decision = slots.preparePublisherRefresh(call); + const policy = refreshPolicy; + if (!policy || decision.action === 'suppress') return decision; + const policySlots = decision.action === 'replace' ? decision.slots : call.slots; + const policyCall = + decision.action === 'replace' + ? Object.freeze({ + requestedSlots: + call.requestedSlots === undefined ? undefined : Object.freeze([...policySlots]), + slots: Object.freeze([...policySlots]), + options: call.options, + }) + : call; + let completion: PromiseLike | undefined; + try { + completion = policy.prepare(policyCall); + } catch { + return decision; + } + if (!completion) return decision; + return Object.freeze({ + action: 'defer' as const, + ...(decision.admission ? { admission: decision.admission } : {}), + completion, + slots: Object.freeze([...policySlots]), + }); + }, }); return options.googletag.observePublisherCalls(observer); }, + installRefreshPolicy: (policy: GptRefreshPolicy): (() => void) | undefined => { + if (!policy || typeof policy.prepare !== 'function' || refreshPolicy) return undefined; + refreshPolicy = policy; + let active = true; + return (): void => { + if (!active) return; + active = false; + if (refreshPolicy === policy) refreshPolicy = undefined; + }; + }, start: (config: unknown): void => { options.slots().start(); options.start?.(config); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 6f28fc04c..9efe63408 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -7,9 +7,16 @@ import { import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; import { PrebidAdmissionContractError, + type PrebidAdapter, type PrebidEventFacade, + type PrebidOperation, type PreparedTrustedBidV1, } from '../../adapters/prebid'; +import type { + GoogletagAdapter, + GoogletagOperation, + GoogletagPublisherRefreshCall, +} from '../../adapters/googletag'; import type { IntegrationActivationContext, IntegrationPrepareContext, @@ -159,6 +166,452 @@ export function createPrebidIntegrationRegistration(release: string): Integratio }); } +const PREBID_REFRESH_TIMEOUT_MS = 1_500; +const MAX_PREBID_REFRESH_AD_UNITS = 64; +const PREBID_REFRESH_TARGETING_KEYS = Object.freeze([ + 'ts_initial', + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', +]); + +export interface PrebidRefreshPolicyOptions { + readonly currentNavigation: () => NavigationSession | undefined; + readonly excludedGamAdUnitPathSuffixes: readonly string[] | (() => readonly string[]); + readonly googletag: Pick; + readonly runSyntheticAuction: ( + slots: readonly object[], + navigation: NavigationSession + ) => PrebidRefreshAuctionOperation; +} + +export interface PrebidRefreshAuctionPreparation { + readonly adUnitCodes: readonly string[]; + readonly adUnits: readonly object[]; +} + +export interface PrebidRefreshAuctionOperation { + readonly completion: Promise; + readonly dispose: () => void; +} + +export interface PrebidSyntheticRefreshRunnerOptions { + readonly prebid: Pick; + readonly prepareAuction: (slots: readonly object[], navigation: NavigationSession) => unknown; + readonly scheduler?: RenderScheduler; +} + +export type PrebidSyntheticRefreshRunner = ( + slots: readonly object[], + navigation: NavigationSession +) => PrebidRefreshAuctionOperation; + +export interface PrebidRefreshPolicy { + readonly dispose: () => void; + readonly prepare: ( + call: Readonly + ) => PromiseLike | undefined; +} + +interface PrebidRefreshNavigationOwner { + active: boolean; + readonly navigation: NavigationSession; + readonly pending: Set; +} + +interface PrebidPendingRefresh { + active: boolean; + auctionOperation: PrebidRefreshAuctionOperation | undefined; + readonly owner: PrebidRefreshNavigationOwner; + operation: GoogletagOperation | undefined; + readonly resolve: () => void; + readonly settle: () => void; +} + +function defaultRefreshScheduler(): RenderScheduler { + return Object.freeze({ + clear: (handle: unknown): void => { + globalThis.clearTimeout(handle as ReturnType); + }, + set: (callback: () => void, milliseconds: number): unknown => + globalThis.setTimeout(callback, milliseconds), + }); +} + +function validRefreshAuctionPreparation( + candidate: unknown +): PrebidRefreshAuctionPreparation | undefined { + try { + const record = ownDataObject(candidate); + if (!record || !Array.isArray(record.adUnits) || !Array.isArray(record.adUnitCodes)) { + return undefined; + } + if ( + !Object.isFrozen(record.adUnits) || + !Object.isFrozen(record.adUnitCodes) || + record.adUnits.length === 0 || + record.adUnits.length > MAX_PREBID_REFRESH_AD_UNITS || + record.adUnits.length !== record.adUnitCodes.length + ) { + return undefined; + } + const codes = new Set(); + for (let index = 0; index < record.adUnits.length; index += 1) { + const code = record.adUnitCodes[index]; + const adUnit = ownDataObject(record.adUnits[index]); + if (!validBoundedString(code, 128) || codes.has(code) || !adUnit || adUnit.code !== code) { + return undefined; + } + codes.add(code); + } + return record as unknown as PrebidRefreshAuctionPreparation; + } catch { + return undefined; + } +} + +/** Run one synthetic refresh auction through the exact current Prebid adapter binding. */ +export function createPrebidSyntheticRefreshRunner( + options: PrebidSyntheticRefreshRunnerOptions +): PrebidSyntheticRefreshRunner { + const scheduler = options.scheduler ?? defaultRefreshScheduler(); + return (slots, navigation): PrebidRefreshAuctionOperation => { + let active = true; + let adapterOperation: PrebidOperation | undefined; + let timer: unknown; + let timerArmed = false; + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const settle = (): void => { + if (!active) return; + active = false; + if (timerArmed) { + timerArmed = false; + try { + scheduler.clear(timer); + } catch { + // The runner's logical completion remains terminal. + } + } + const operation = adapterOperation; + adapterOperation = undefined; + try { + operation?.dispose(); + } catch { + // Adapter cleanup cannot prevent the deferred GPT call from resuming. + } + resolveCompletion(); + }; + const handle = Object.freeze({ completion, dispose: settle }); + + let prepared: PrebidRefreshAuctionPreparation | undefined; + try { + if (!navigation.isCurrent()) { + settle(); + return handle; + } + prepared = validRefreshAuctionPreparation( + options.prepareAuction(Object.freeze([...slots]), navigation) + ); + } catch { + prepared = undefined; + } + if (!prepared) { + settle(); + return handle; + } + + try { + const codes = Object.freeze([...prepared.adUnitCodes]); + const adUnits = Object.freeze([...prepared.adUnits]); + const operation = options.prebid.run( + (prebid) => + new Promise((resolveRequest) => { + let requestActive = true; + const finishRequest = (applyTargeting: boolean): void => { + if (!requestActive) return; + requestActive = false; + if (timerArmed) { + timerArmed = false; + try { + scheduler.clear(timer); + } catch { + // The request completion latch remains terminal. + } + } + if (active && applyTargeting) { + try { + prebid.setTargetingForGpt(codes); + } catch { + // Targeting failure still resumes the exact deferred GPT request. + } + } + resolveRequest(); + }; + try { + prebid.requestBids( + Object.freeze({ + adUnits, + bidsBackHandler: () => finishRequest(true), + timeout: PREBID_REFRESH_TIMEOUT_MS, + }) + ); + } catch { + finishRequest(false); + return; + } + if (!requestActive || !active) return; + let installedTimer: unknown; + try { + installedTimer = scheduler.set(() => finishRequest(true), PREBID_REFRESH_TIMEOUT_MS); + if (requestActive && active) { + timer = installedTimer; + timerArmed = true; + } else { + try { + scheduler.clear(installedTimer); + } catch { + // A synchronously-fired timeout is already terminal. + } + } + } catch { + finishRequest(true); + } + }), + Object.freeze({ signal: navigation.signal }) + ); + adapterOperation = operation; + if (!active) { + try { + operation.dispose(); + } catch { + // The runner's exact completion latch has already settled. + } + } else { + void operation.result.then(settle, settle); + } + } catch { + settle(); + } + return handle; + }; +} + +/** Defer one publisher refresh through navigation-owned targeting cleanup and Prebid work. */ +export function createPrebidRefreshPolicy( + options: PrebidRefreshPolicyOptions +): PrebidRefreshPolicy { + const owners = new WeakMap(); + const pending = new Set(); + let disposed = false; + + const currentNavigation = (): NavigationSession | undefined => { + try { + const navigation = options.currentNavigation(); + return navigation?.isCurrent() ? navigation : undefined; + } catch { + return undefined; + } + }; + + const ownerFor = (navigation: NavigationSession): PrebidRefreshNavigationOwner | undefined => { + const current = owners.get(navigation); + if (current?.active) return current; + const owner: PrebidRefreshNavigationOwner = { + active: true, + navigation, + pending: new Set(), + }; + try { + navigation.onDispose('prebid-refresh-policy', () => { + owner.active = false; + owners.delete(navigation); + const snapshot = [...owner.pending]; + for (let index = 0; index < snapshot.length; index += 1) snapshot[index]?.settle(); + }); + } catch { + return undefined; + } + if (!navigation.isCurrent()) return undefined; + owners.set(navigation, owner); + return owner; + }; + + const prepare = ( + call: Readonly + ): PromiseLike | undefined => { + if (disposed) return undefined; + const navigation = currentNavigation(); + if (!navigation) return undefined; + let slots: readonly object[]; + let suffixes: readonly string[]; + try { + if (!Array.isArray(call.slots)) return undefined; + const snapshot: object[] = []; + for (let index = 0; index < call.slots.length; index += 1) { + const slot = call.slots[index]; + if ((typeof slot !== 'object' && typeof slot !== 'function') || slot === null) { + return undefined; + } + snapshot.push(slot); + } + slots = Object.freeze(snapshot); + } catch { + return undefined; + } + try { + const configuredSuffixes = + typeof options.excludedGamAdUnitPathSuffixes === 'function' + ? options.excludedGamAdUnitPathSuffixes() + : options.excludedGamAdUnitPathSuffixes; + suffixes = Object.freeze([...configuredSuffixes]); + } catch { + suffixes = Object.freeze([]); + } + const owner = ownerFor(navigation); + if (!owner) return undefined; + + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const requestReference: { value?: PrebidPendingRefresh } = {}; + const settle = (): void => { + const request = requestReference.value; + if (!request?.active) return; + request.active = false; + pending.delete(request); + request.owner.pending.delete(request); + const operation = request.operation; + request.operation = undefined; + try { + operation?.dispose(); + } catch { + // GPT cleanup failure cannot prevent the publisher refresh from resuming. + } + const auctionOperation = request.auctionOperation; + request.auctionOperation = undefined; + try { + auctionOperation?.dispose(); + } catch { + // Prebid cleanup failure cannot prevent the publisher refresh from resuming. + } + request.resolve(); + }; + const request: PrebidPendingRefresh = { + active: true, + auctionOperation: undefined, + operation: undefined, + owner, + resolve: resolveCompletion, + settle, + }; + requestReference.value = request; + pending.add(request); + owner.pending.add(request); + + try { + const operation = options.googletag.run((gpt) => { + const eligible: object[] = []; + for (let slotIndex = 0; slotIndex < slots.length; slotIndex += 1) { + const slot = slots[slotIndex]; + if (!slot) continue; + let clearFailed = false; + for (let keyIndex = 0; keyIndex < PREBID_REFRESH_TARGETING_KEYS.length; keyIndex += 1) { + try { + gpt.clearTargeting(slot, PREBID_REFRESH_TARGETING_KEYS[keyIndex]); + } catch { + clearFailed = true; + } + } + if (clearFailed) { + eligible.push(slot); + continue; + } + let adUnitPath: unknown; + try { + adUnitPath = gpt.adUnitPath?.(slot); + } catch { + eligible.push(slot); + continue; + } + if (typeof adUnitPath !== 'string') { + eligible.push(slot); + continue; + } + let excluded = false; + for (let suffixIndex = 0; suffixIndex < suffixes.length; suffixIndex += 1) { + if (adUnitPath.endsWith(suffixes[suffixIndex] as string)) { + excluded = true; + break; + } + } + if (!excluded) eligible.push(slot); + } + return Object.freeze(eligible); + }); + request.operation = operation; + if (!request.active) { + try { + operation.dispose(); + } catch { + // The terminal request already resumed GPT. + } + return completion; + } + void operation.result.then((eligible) => { + if ( + !request.active || + !owner.active || + currentNavigation() !== navigation || + !navigation.isCurrent() + ) { + settle(); + return; + } + if (eligible.length === 0) { + settle(); + return; + } + let auction: PrebidRefreshAuctionOperation; + try { + auction = options.runSyntheticAuction(Object.freeze([...eligible]), navigation); + } catch { + settle(); + return; + } + request.auctionOperation = auction; + if (!request.active) { + try { + auction.dispose(); + } catch { + // The policy's completion latch has already settled. + } + return; + } + void auction.completion.then(settle, settle); + }, settle); + } catch { + settle(); + } + return completion; + }; + + return Object.freeze({ + dispose: (): void => { + if (disposed) return; + disposed = true; + const snapshot = [...pending]; + for (let index = 0; index < snapshot.length; index += 1) snapshot[index]?.settle(); + }, + prepare, + }); +} + export type PrebidBidPublicationFailureReason = | 'descriptor_invalid' | 'prebid_admission_failed' diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts index be5e8fa26..2a7cff812 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/startup.ts @@ -3,6 +3,13 @@ import type { PrebidEventFacade, PrebidTrustedServerAuctionV1, } from '../../adapters/prebid'; +import type { GoogletagPublisherRefreshCall } from '../../adapters/googletag'; + +interface RefreshPolicyCapability { + readonly prepare: ( + call: Readonly + ) => PromiseLike | undefined; +} export interface PrebidStartup { readonly activate: () => () => void; @@ -14,6 +21,11 @@ export interface PrebidStartupOptions { readonly onAuction: (auction: Readonly) => void; readonly onAuctionEnd: (event: unknown, prebid: Readonly) => void; readonly prebid: Pick; + readonly refresh?: Readonly<{ + readonly configure?: (config: unknown) => void; + readonly install: (policy: RefreshPolicyCapability) => (() => void) | undefined; + readonly policy: RefreshPolicyCapability & Readonly<{ dispose: () => void }>; + }>; readonly start?: (config: unknown) => void; } @@ -26,6 +38,7 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu let activationEffects: (() => void) | undefined; let bidderOperation: ReturnType | undefined; let bidderEffects: (() => void) | undefined; + let refreshPolicyRelease: (() => void) | undefined; const retainEffects = ( result: Promise, @@ -63,6 +76,25 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu retainEffects(activationOperation.result, (release) => { activationEffects = release; }); + const refresh = options.refresh; + if (refresh) { + try { + refreshPolicyRelease = refresh.install(refresh.policy); + if (!refreshPolicyRelease) throw new Error('Prebid refresh policy is unavailable'); + } catch (error) { + released = true; + try { + disposeOwnedOperation(activationOperation, activationEffects); + } finally { + try { + refresh.policy.dispose(); + } finally { + options.dispose(); + } + } + throw error; + } + } return (): void => { if (released) return; released = true; @@ -72,7 +104,15 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu try { disposeOwnedOperation(activationOperation, activationEffects); } finally { - options.dispose(); + try { + options.refresh?.policy.dispose(); + } finally { + try { + refreshPolicyRelease?.(); + } finally { + options.dispose(); + } + } } } }; @@ -80,13 +120,14 @@ export function createPrebidStartup(options: PrebidStartupOptions): PrebidStartu start: (config: unknown): void => { if (!activated || released || started) throw new Error('Prebid startup is unavailable'); started = true; - bidderOperation = options.prebid.run((prebid) => - prebid.registerTrustedServerBidder(options.onAuction) - ); - retainEffects(bidderOperation.result, (release) => { - bidderEffects = release; - }); try { + options.refresh?.configure?.(config); + bidderOperation = options.prebid.run((prebid) => + prebid.registerTrustedServerBidder(options.onAuction) + ); + retainEffects(bidderOperation.result, (release) => { + bidderEffects = release; + }); options.start?.(config); } finally { options.prebid.notifyReady(); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index f60b509ea..ed701f29c 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1950,6 +1950,7 @@ describe('browser googletag adapter readiness', () => { if (key === undefined) targeting.clear(); else targeting.delete(key); }), + getAdUnitPath: vi.fn(() => '/publisher/example'), getTargeting: vi.fn((key: string) => targeting.get(key) ?? []), setTargeting: vi.fn((key: string, value: string | readonly string[]) => { targeting.set(key, typeof value === 'string' ? [value] : [...value]); @@ -1964,6 +1965,7 @@ describe('browser googletag adapter readiness', () => { const unsubscribe = gpt.subscribe('slotRequested', listener); gpt.setTargeting(slot, 'hb_adid', 'reservation'); expect(gpt.getTargeting(slot, 'hb_adid')).toEqual(['reservation']); + expect(gpt.adUnitPath?.(slot)).toBe('/publisher/example'); gpt.refresh([slot], { changeCorrelator: false }); expect(gpt.slots()).toEqual([slot]); expect(Object.isFrozen(gpt.slots())).toBe(true); @@ -2098,6 +2100,7 @@ describe('browser googletag adapter readiness', () => { expect(observer.display).not.toHaveBeenCalled(); expect(observer.refresh).toHaveBeenCalledExactlyOnceWith({ + options: { changeCorrelator: true }, requestedSlots: [slot], slots: [slot], }); @@ -2181,6 +2184,77 @@ describe('browser googletag adapter readiness', () => { expect(refreshAdmission.rollback).not.toHaveBeenCalled(); }); + it('defers one explicit refresh and forwards the complete snapshot with exact options once', async () => { + const ready = createReadyGoogletag(); + const first = Object.freeze({ id: 'first' }); + const second = Object.freeze({ id: 'second' }); + const options = Object.freeze({ changeCorrelator: false, publisher: 'exact-options' }); + const originalSlots = [first, second]; + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const admission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + const nativeRefresh = vi.fn(); + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + adapter.observePublisherCalls({ + refresh: () => + Object.freeze({ + action: 'defer' as const, + admission, + completion, + slots: Object.freeze([first, second]), + }), + }); + + expect(ready.pubads.refresh(originalSlots, options)).toBeUndefined(); + originalSlots.length = 0; + expect(nativeRefresh).not.toHaveBeenCalled(); + + complete(); + await completion; + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([first, second], options); + expect(admission.commit).toHaveBeenCalledOnce(); + expect(admission.rollback).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + }); + + it('forwards a deferred global refresh exactly once when its observer is released', async () => { + const ready = createReadyGoogletag(); + const first = Object.freeze({ id: 'first' }); + const second = Object.freeze({ id: 'second' }); + const options = Object.freeze({ changeCorrelator: true }); + ready.pubads.getSlots.mockReturnValue([first, second]); + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const nativeRefresh = vi.fn(); + ready.pubads.refresh = nativeRefresh; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const release = adapter.observePublisherCalls({ + refresh: () => + Object.freeze({ + action: 'defer' as const, + completion, + slots: Object.freeze([first, second]), + }), + }); + + ready.pubads.refresh(undefined, options); + expect(nativeRefresh).not.toHaveBeenCalled(); + release(); + expect(nativeRefresh).toHaveBeenCalledExactlyOnceWith([first, second], options); + + complete(); + await completion; + await Promise.resolve(); + expect(nativeRefresh).toHaveBeenCalledOnce(); + }); + it('rolls back each unconsumed publisher admission on native throw and rethrows the exact error', () => { const ready = createReadyGoogletag(); const displayError = new Error('exact display failure'); diff --git a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts index 326e77051..501f950dd 100644 --- a/crates/trusted-server-js/lib/test/adapters/prebid.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/prebid.test.ts @@ -68,6 +68,7 @@ function createReadyPrebid( }, renderAd: vi.fn(), requestBids: vi.fn(), + setTargetingForGPTAsync: vi.fn(), }; const stamp = options.stamp ?? createStamp(); Object.defineProperty(pbjs, '__trustedServerArtifactV1', { @@ -93,6 +94,7 @@ describe('browser Prebid adapter readiness', () => { prebid.addAdUnits([{ code: 'slot-a' }]); prebid.registerBidAdapter(undefined, 'trustedServer', { code: 'trustedServer' }); prebid.requestBids({ adUnitCodes: ['slot-a'] }); + prebid.setTargetingForGpt(['slot-a']); prebid.renderAd({}, 'bid-a'); return prebid.highestBids('slot-a'); }); @@ -104,6 +106,7 @@ describe('browser Prebid adapter readiness', () => { code: 'trustedServer', }); expect(ready.pbjs.requestBids).toHaveBeenCalledTimes(1); + expect(ready.pbjs.setTargetingForGPTAsync).toHaveBeenCalledExactlyOnceWith(['slot-a']); expect(ready.pbjs.renderAd).toHaveBeenCalledWith({}, 'bid-a'); }); @@ -619,6 +622,7 @@ describe('browser Prebid adapter readiness', () => { 'registerBidAdapter', 'renderAd', 'requestBids', + 'setTargetingForGPTAsync', ] as const) { const ready = createReadyPrebid(); Object.defineProperty(ready.pbjs, method, { value: undefined }); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 344444e8b..8d255d48d 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -29,6 +29,7 @@ import { createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; +import { TRACE_PANEL_ID } from '../../src/core/trace'; import type { BrowserAuctionBidV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; @@ -181,6 +182,7 @@ function synchronousPrebidAdapter() { ), renderAd: vi.fn(), requestBids: vi.fn(), + setTargetingForGpt: vi.fn(), subscribe: vi.fn( ( eventType: string, @@ -2212,7 +2214,7 @@ describe('browser composition', () => { bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, - diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + diagnostics: { version: 1, renderTraceOverlay: true, gpt: { active: false } }, }, kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, }, @@ -2305,12 +2307,18 @@ describe('browser composition', () => { slotId: 'programmatic-slot', path: 'auction', rendered: true, + injected: true, + elementId: 'programmatic-slot', servedFrom: 'inline', count: 1, }) ); expect(renderTrace?.history()).toHaveLength(1); expect(Object.isFrozen(renderTrace?.history()[0])).toBe(true); + const programmaticSlot = document.getElementById('programmatic-slot'); + expect(programmaticSlot?.getAttribute('data-ts-rendered')).toBe('true'); + expect(programmaticSlot?.getAttribute('data-ts-injected')).toBe('true'); + expect(document.getElementById(TRACE_PANEL_ID)?.textContent).toContain('programmatic-slot'); expect(target).not.toHaveProperty('renders'); expect(target).not.toHaveProperty('renderLog'); expect(target).not.toHaveProperty('renderSeq'); @@ -2389,6 +2397,10 @@ describe('browser composition', () => { expect(contextContributor).toHaveBeenCalledTimes(4); expect(auctionFetcher).toHaveBeenCalledTimes(4); + session?.currentNavigation?.dispose(); + expect(renderTrace?.current()).toEqual({}); + expect(programmaticSlot?.hasAttribute('data-ts-rendered')).toBe(false); + composition.runtime.dispose(); expect(() => api.addAdUnits(programmatic)).toThrowError( expect.objectContaining({ name: 'AdUnitRegistrationError', code: 'slot_collision' }) diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts index 99b207791..cead76a52 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/startup.test.ts @@ -58,7 +58,11 @@ describe('GPT startup bridge', () => { action: 'suppress', }); expect( - observer?.refresh?.({ requestedSlots: undefined, slots: Object.freeze([slot]) }) + observer?.refresh?.({ + requestedSlots: undefined, + slots: Object.freeze([slot]), + options: undefined, + }) ).toEqual({ action: 'suppress' }); observer?.destroySlots?.({ slots: Object.freeze([slot, {}]) }); expect(slots.recordPublisherDestruction).toHaveBeenCalledTimes(2); @@ -89,4 +93,66 @@ describe('GPT startup bridge', () => { expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); }); + + it('installs one optional reversible Prebid refresh policy into the sole GPT observer', () => { + let observer: GoogletagPublisherCallObserver | undefined; + const observePublisherCalls = vi.fn((candidate: GoogletagPublisherCallObserver) => { + observer = candidate; + return vi.fn(); + }); + const adapter = Object.freeze({ observePublisherCalls }) as unknown as GoogletagAdapter; + const slot = Object.freeze({ id: 'slot' }); + const admission = Object.freeze({ commit: vi.fn(), rollback: vi.fn() }); + const completion = Promise.resolve(); + const slots = Object.freeze({ + claimPublisherGptSlot: vi.fn(() => Object.freeze({ action: 'forward' as const })), + preparePublisherDisplay: vi.fn(() => Object.freeze({ action: 'forward' as const })), + preparePublisherRefresh: vi.fn(() => + Object.freeze({ action: 'forward' as const, admission }) + ), + recordPublisherDestruction: vi.fn(), + start: vi.fn(), + }) as unknown as Pick< + SlotService, + | 'claimPublisherGptSlot' + | 'preparePublisherDisplay' + | 'preparePublisherRefresh' + | 'recordPublisherDestruction' + | 'start' + >; + const startup = createGptStartup({ googletag: adapter, slots: () => slots }); + const boundary = startup as typeof startup & { + installRefreshPolicy: ( + policy: Readonly<{ prepare: (call: unknown) => PromiseLike | undefined }> + ) => (() => void) | undefined; + }; + const prepare = vi.fn(() => completion); + const release = boundary.installRefreshPolicy(Object.freeze({ prepare })); + + expect(release).toBeTypeOf('function'); + expect( + boundary.installRefreshPolicy(Object.freeze({ prepare: vi.fn(() => completion) })) + ).toBeUndefined(); + startup.activate(); + const call = Object.freeze({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + options: Object.freeze({ changeCorrelator: false }), + }); + expect(observer?.refresh?.(call)).toEqual({ + action: 'defer', + admission, + completion, + slots: [slot], + }); + expect(prepare).toHaveBeenCalledExactlyOnceWith(call); + + release?.(); + release?.(); + expect(observer?.refresh?.(call)).toEqual({ action: 'forward', admission }); + expect(prepare).toHaveBeenCalledOnce(); + expect(boundary.installRefreshPolicy(Object.freeze({ prepare: vi.fn() }))).toBeTypeOf( + 'function' + ); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index 2ea424d0c..ebec85968 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import { PrebidAdmissionContractError } from '../../../src/adapters/prebid'; +import type { GoogletagAdapter } from '../../../src/adapters/googletag'; import { + PrebidAdmissionContractError, + type PrebidAdapter, + type PrebidFacade, +} from '../../../src/adapters/prebid'; +import { + createPrebidRefreshPolicy, createPrebidSelectionCoordinator, + createPrebidSyntheticRefreshRunner, createPrebidIntegrationRegistration, publishPrebidBid, type PrebidBidPublicationInput, @@ -255,6 +262,368 @@ describe('transactional Prebid integration module', () => { }); }); +describe('RCJ-PREBID-04 prospective refresh policy', () => { + function refreshHarness( + excludedGamAdUnitPathSuffixes: readonly string[] | (() => readonly string[]) + ) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(3); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const clearCalls: Array = []; + const operationDisposals: Array> = []; + const googletag = { + run: vi.fn((command: (gpt: object) => unknown) => { + const dispose = vi.fn(); + operationDisposals.push(dispose); + const facade = Object.freeze({ + adUnitPath: (slot: object) => { + const getter = Reflect.get(slot, 'getAdUnitPath'); + if (typeof getter !== 'function') return undefined; + return Reflect.apply(getter, slot, []); + }, + clearTargeting: (slot: object, key: string) => { + clearCalls.push([slot, key]); + const clear = Reflect.get(slot, 'clearTargeting'); + if (typeof clear === 'function') return Reflect.apply(clear, slot, [key]); + return undefined; + }, + }); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose, + }); + }), + }; + const auctionDisposals: Array> = []; + const runSyntheticAuction = vi.fn((_slots: readonly object[]) => { + const dispose = vi.fn(); + auctionDisposals.push(dispose); + return Object.freeze({ completion: Promise.resolve(), dispose }); + }); + const policy = createPrebidRefreshPolicy({ + currentNavigation: () => navigation, + excludedGamAdUnitPathSuffixes, + googletag: googletag as unknown as Pick, + runSyntheticAuction, + }); + return { + auctionDisposals, + clearCalls, + navigation, + operationDisposals, + policy, + runSyntheticAuction, + runtime, + }; + } + + it('clears every target then filters only literal case-sensitive suffix matches', async () => { + const harness = refreshHarness(['/tracking']); + const excluded = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/tracking'), + }; + const caseMismatch = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/Tracking'), + }; + const trailingSlash = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => '/network/tracking/'), + }; + const missing = { clearTargeting: vi.fn() }; + const nonString = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => 42), + }; + const throwing = { + clearTargeting: vi.fn(), + getAdUnitPath: vi.fn(() => { + throw new Error('path unavailable'); + }), + }; + const clearFailure = { + clearTargeting: vi.fn((key: string) => { + if (key === 'hb_adid') throw new Error('clear unavailable'); + }), + getAdUnitPath: vi.fn(() => '/network/tracking'), + }; + const slots = Object.freeze([ + excluded, + caseMismatch, + trailingSlash, + missing, + nonString, + throwing, + clearFailure, + ]); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: slots, slots, options: Object.freeze({ exact: true }) }) + ); + + const expectedKeys = [ + 'ts_initial', + 'hb_pb', + 'hb_bidder', + 'hb_adid', + 'hb_cache_host', + 'hb_cache_path', + ]; + for (const slot of slots) { + expect( + harness.clearCalls.filter(([target]) => target === slot).map(([, key]) => key) + ).toEqual(expectedKeys); + } + expect(harness.runSyntheticAuction).toHaveBeenCalledExactlyOnceWith( + [caseMismatch, trailingSlash, missing, nonString, throwing, clearFailure], + harness.navigation + ); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('skips the synthetic auction when all targets are excluded', async () => { + const harness = refreshHarness(['/skip']); + const slots = Object.freeze([ + { getAdUnitPath: () => '/one/skip' }, + { getAdUnitPath: () => '/two/skip' }, + ]); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: undefined, slots, options: undefined }) + ); + + expect(harness.runSyntheticAuction).not.toHaveBeenCalled(); + expect(harness.clearCalls).toHaveLength(slots.length * 6); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('reads the configured exclusion snapshot only when the activated policy prepares', async () => { + let configuredSuffixes: readonly string[] = Object.freeze([]); + const harness = refreshHarness(() => configuredSuffixes); + configuredSuffixes = Object.freeze(['/configured-after-activation']); + const slot = Object.freeze({ getAdUnitPath: () => '/network/configured-after-activation' }); + + await harness.policy.prepare( + Object.freeze({ requestedSlots: Object.freeze([slot]), slots: Object.freeze([slot]) }) + ); + + expect(harness.runSyntheticAuction).not.toHaveBeenCalled(); + expect(harness.clearCalls).toHaveLength(6); + harness.policy.dispose(); + harness.runtime.dispose(); + }); + + it('settles pending work on navigation abort and ignores a late auction completion', async () => { + const harness = refreshHarness([]); + let finishAuction!: () => void; + const auction = new Promise((resolve) => { + finishAuction = resolve; + }); + const auctionDispose = vi.fn(); + harness.runSyntheticAuction.mockReturnValue( + Object.freeze({ completion: auction, dispose: auctionDispose }) + ); + const slot = Object.freeze({ getAdUnitPath: () => '/eligible' }); + const completion = harness.policy.prepare( + Object.freeze({ + requestedSlots: Object.freeze([slot]), + slots: Object.freeze([slot]), + options: undefined, + }) + ); + await vi.waitFor(() => expect(harness.runSyntheticAuction).toHaveBeenCalledOnce()); + + harness.runtime.replaceNavigation(); + await expect(completion).resolves.toBeUndefined(); + expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect(auctionDispose).toHaveBeenCalledOnce(); + finishAuction(); + await auction; + await Promise.resolve(); + expect(harness.runSyntheticAuction).toHaveBeenCalledOnce(); + harness.policy.dispose(); + }); + + it('settles pending work when the refresh policy is disposed', async () => { + const harness = refreshHarness([]); + let finishAuction!: () => void; + const auction = new Promise((resolve) => { + finishAuction = resolve; + }); + const auctionDispose = vi.fn(); + harness.runSyntheticAuction.mockReturnValue( + Object.freeze({ completion: auction, dispose: auctionDispose }) + ); + const slot = Object.freeze({ getAdUnitPath: () => '/eligible' }); + const completion = harness.policy.prepare( + Object.freeze({ requestedSlots: Object.freeze([slot]), slots: Object.freeze([slot]) }) + ); + await vi.waitFor(() => expect(harness.runSyntheticAuction).toHaveBeenCalledOnce()); + + harness.policy.dispose(); + harness.policy.dispose(); + await expect(completion).resolves.toBeUndefined(); + expect(harness.operationDisposals[0]).toHaveBeenCalledOnce(); + expect(auctionDispose).toHaveBeenCalledOnce(); + finishAuction(); + await auction; + harness.runtime.dispose(); + }); +}); + +describe('RCJ-PREBID-04 adapter-backed synthetic refresh runner', () => { + function runnerHarness(options: Readonly<{ requestThrows?: boolean }> = {}) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(4); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected navigation'); + const navigation = navigationResult.value; + const order: string[] = []; + let requestOptions: + | Readonly<{ + adUnits: readonly object[]; + bidsBackHandler: () => void; + timeout: number; + }> + | undefined; + const facade = Object.freeze({ + requestBids: vi.fn((received: unknown) => { + order.push('request'); + if (options.requestThrows) throw new Error('request unavailable'); + requestOptions = received as typeof requestOptions; + }), + setTargetingForGpt: vi.fn((codes: readonly string[]) => { + order.push(`target:${codes.join(',')}`); + }), + }) as unknown as Readonly; + const adapterDispose = vi.fn(); + const prebid = Object.freeze({ + run: vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: adapterDispose, + }) + ), + }) as unknown as Pick; + let deadline: (() => void) | undefined; + const timerHandle = Object.freeze({}); + const clear = vi.fn(); + const slot = Object.freeze({ id: 'slot-a' }); + const adUnit = Object.freeze({ code: 'slot-a', bids: Object.freeze([]) }); + const prepareAuction = vi.fn(() => + Object.freeze({ + adUnitCodes: Object.freeze(['slot-a']), + adUnits: Object.freeze([adUnit]), + }) + ); + const runner = createPrebidSyntheticRefreshRunner({ + prebid, + prepareAuction, + scheduler: Object.freeze({ + clear, + set: (callback: () => void, milliseconds: number) => { + expect(milliseconds).toBe(1_500); + deadline = callback; + return timerHandle; + }, + }), + }); + return { + adapterDispose, + clear, + deadline: () => deadline, + facade, + navigation, + order, + prepareAuction, + requestOptions: () => requestOptions, + runner, + runtime, + slot, + timerHandle, + }; + } + + it('requests eligible ad units then applies only their scoped targeting before completion', async () => { + const harness = runnerHarness(); + const operation = harness.runner(Object.freeze([harness.slot]), harness.navigation); + + expect(harness.order).toEqual(['request']); + expect(harness.prepareAuction).toHaveBeenCalledExactlyOnceWith( + [harness.slot], + harness.navigation + ); + expect(harness.requestOptions()).toMatchObject({ + adUnits: [{ code: 'slot-a', bids: [] }], + timeout: 1_500, + }); + harness.requestOptions()?.bidsBackHandler(); + await expect(operation.completion).resolves.toBeUndefined(); + + expect(harness.order).toEqual(['request', 'target:slot-a']); + expect(harness.clear).toHaveBeenCalledExactlyOnceWith(harness.timerHandle); + expect(harness.adapterDispose).toHaveBeenCalledOnce(); + harness.runtime.dispose(); + }); + + it('uses one targeting/settlement latch for timeout, disposal, and late callbacks', async () => { + const timedOut = runnerHarness(); + const timedOutOperation = timedOut.runner(Object.freeze([timedOut.slot]), timedOut.navigation); + const lateTimeoutCallback = timedOut.requestOptions()?.bidsBackHandler; + timedOut.deadline()?.(); + await expect(timedOutOperation.completion).resolves.toBeUndefined(); + lateTimeoutCallback?.(); + expect(timedOut.order).toEqual(['request', 'target:slot-a']); + expect(timedOut.adapterDispose).toHaveBeenCalledOnce(); + timedOut.runtime.dispose(); + + const disposed = runnerHarness(); + const disposedOperation = disposed.runner(Object.freeze([disposed.slot]), disposed.navigation); + const lateDisposedCallback = disposed.requestOptions()?.bidsBackHandler; + disposedOperation.dispose(); + disposedOperation.dispose(); + await expect(disposedOperation.completion).resolves.toBeUndefined(); + lateDisposedCallback?.(); + disposed.deadline()?.(); + expect(disposed.order).toEqual(['request']); + expect(disposed.adapterDispose).toHaveBeenCalledOnce(); + disposed.runtime.dispose(); + }); + + it('forwards completion without targeting when requestBids throws', async () => { + const harness = runnerHarness({ requestThrows: true }); + const operation = harness.runner(Object.freeze([harness.slot]), harness.navigation); + + await expect(operation.completion).resolves.toBeUndefined(); + expect(harness.order).toEqual(['request']); + expect(harness.facade.setTargetingForGpt).not.toHaveBeenCalled(); + expect(harness.adapterDispose).toHaveBeenCalledOnce(); + expect(harness.deadline()).toBeUndefined(); + harness.runtime.dispose(); + }); +}); + describe('ordered Prebid bid publication', () => { function preparePublication() { const runtime = createRuntimeSession({ @@ -770,6 +1139,61 @@ describe('Prebid selection coordination', () => { expect(disposed.timers).toHaveLength(0); }); + it('aborts every ad unit in one exact auction and releases each short lease at expiry', () => { + const harness = prepareSelection(); + const first = harness.admitted('j', 'slot-one'); + const second = harness.admitted('k', 'slot-two'); + + harness.coordinator.abort(harness.navigation, 'auction-one'); + + expect(harness.reservations.recognize(first.bid.adId)).toMatchObject({ state: 'aborted' }); + expect(harness.reservations.recognize(second.bid.adId)).toMatchObject({ state: 'aborted' }); + expect(harness.timers).toHaveLength(0); + expect(harness.navigation.snapshotInventoryForTest().batches).toBe(0); + + harness.setNow(10_000); + expect(harness.reservations.recognize(first.bid.adId)).toEqual({ recognized: false }); + expect(harness.reservations.recognize(second.bid.adId)).toEqual({ recognized: false }); + expect(harness.reservations.snapshotInventoryForTest().size).toBe(0); + harness.runtime.dispose(); + }); + + it('selects independently across multiple ad units without promoting either group loser', () => { + const harness = prepareSelection(); + const first = harness.admitted('l', 'slot-one'); + const firstLoser = harness.admitted('m', 'slot-one'); + const second = harness.admitted('n', 'slot-two'); + const secondLoser = harness.admitted('o', 'slot-two'); + + harness.coordinator.auctionEnded( + Object.freeze({ auctionId: 'auction-one' }), + Object.freeze({ + highestBids: (adUnitCode?: string) => { + const selected = adUnitCode === 'slot-one' ? first : second; + return Object.freeze([ + Object.freeze({ + ...selected.bid, + adUnitCode: selected.adUnitCode, + auctionId: selected.auctionId, + }), + ]); + }, + }) + ); + + expect(harness.reservations.recognize(first.bid.adId)).toMatchObject({ state: 'renderable' }); + expect(harness.reservations.recognize(second.bid.adId)).toMatchObject({ state: 'renderable' }); + expect(harness.reservations.recognize(firstLoser.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.reservations.recognize(secondLoser.bid.adId)).toMatchObject({ + state: 'unselected', + }); + expect(harness.attempts).toHaveLength(2); + expect(harness.timers).toHaveLength(0); + harness.runtime.dispose(); + }); + it('rolls back a scheduler that invokes the deadline before timer publication returns', () => { const harness = prepareSelection({ synchronousTimer: true }); const bid = harness.admitted('g'); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts index 0431113ea..aaae60d1f 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/startup.test.ts @@ -7,6 +7,7 @@ import type { PrebidTrustedServerAuctionV1, } from '../../../src/adapters/prebid'; import { createPrebidStartup } from '../../../src/integrations/prebid/startup'; +import type { GptRefreshPolicy } from '../../../src/integrations/gpt/startup'; describe('Prebid startup bridge', () => { it('installs one reversible bidder/event operation before starting the external boundary', async () => { @@ -133,4 +134,126 @@ describe('Prebid startup bridge', () => { expect(releaseEffects).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); }); + + it('installs the TS auctionEnd listener before startup can add a publisher callback', async () => { + const listeners: Array<(event: unknown, prebid: Readonly) => void> = []; + const order: string[] = []; + const eventFacade = Object.freeze({ highestBids: vi.fn(() => Object.freeze([])) }); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn(() => vi.fn()), + subscribe: vi.fn( + ( + eventType: string, + listener: (event: unknown, prebid: Readonly) => void + ) => { + expect(eventType).toBe('auctionEnd'); + listeners.push(listener); + return vi.fn(); + } + ), + }) as unknown as Readonly; + const run = vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: vi.fn(), + }) + ); + const startup = createPrebidStartup({ + dispose: vi.fn(), + onAuction: vi.fn(), + onAuctionEnd: () => order.push('trusted-server'), + prebid: Object.freeze({ run, notifyReady: vi.fn() }) as unknown as PrebidAdapter, + start: () => { + listeners.push(() => order.push('publisher')); + }, + }); + + startup.activate(); + await Promise.resolve(); + startup.start(Object.freeze({})); + await Promise.resolve(); + const event = Object.freeze({ auctionId: 'auction-one' }); + for (const listener of listeners) listener(event, eventFacade); + + expect(order).toEqual(['trusted-server', 'publisher']); + }); + + it('installs, configures, and releases one runtime-owned GPT refresh policy', async () => { + const order: string[] = []; + const operationDispose = vi.fn(); + const facade = Object.freeze({ + registerTrustedServerBidder: vi.fn(() => vi.fn()), + subscribe: vi.fn(() => vi.fn()), + }) as unknown as Readonly; + const prebid = Object.freeze({ + notifyReady: vi.fn(), + run: vi.fn((command: (prebid: Readonly) => unknown) => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(command(facade)), + dispose: operationDispose, + }) + ), + }) as unknown as Pick; + const policy = Object.freeze({ prepare: vi.fn(), dispose: vi.fn() }); + const releasePolicy = vi.fn(() => order.push('release-policy')); + const install = vi.fn((_policy: GptRefreshPolicy) => { + order.push('install-policy'); + return releasePolicy; + }); + const configure = vi.fn((_config: unknown) => order.push('configure-policy')); + const start = vi.fn(() => order.push('start-prebid')); + const startup = createPrebidStartup({ + dispose: vi.fn(), + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid, + refresh: Object.freeze({ configure, install, policy }), + start, + }); + + const release = startup.activate(); + expect(install).toHaveBeenCalledExactlyOnceWith(policy); + const config = Object.freeze({ excludedGamAdUnitPathSuffixes: Object.freeze(['/skip']) }); + startup.start(config); + expect(configure).toHaveBeenCalledExactlyOnceWith(config); + expect(order).toEqual(['install-policy', 'configure-policy', 'start-prebid']); + + release(); + release(); + expect(policy.dispose).toHaveBeenCalledOnce(); + expect(releasePolicy).toHaveBeenCalledOnce(); + }); + + it('unwinds the adapter and policy when GPT refuses a second refresh owner', () => { + const operationDispose = vi.fn(); + const prebid = Object.freeze({ + notifyReady: vi.fn(), + run: vi.fn(() => + Object.freeze({ + status: 'present' as const, + result: Promise.resolve(vi.fn()), + dispose: operationDispose, + }) + ), + }) as unknown as Pick; + const policy = Object.freeze({ prepare: vi.fn(), dispose: vi.fn() }); + const dispose = vi.fn(); + const startup = createPrebidStartup({ + dispose, + onAuction: vi.fn(), + onAuctionEnd: vi.fn(), + prebid, + refresh: Object.freeze({ + install: vi.fn(() => undefined), + policy, + }), + }); + + expect(() => startup.activate()).toThrow('Prebid refresh policy is unavailable'); + expect(operationDispose).toHaveBeenCalledOnce(); + expect(policy.dispose).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 459d72db6..c2ba251fa 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -151,7 +151,9 @@ describe('external bundle + served shim evaluated together', () => { bidderAliases: artifactManifest.bidderAliases, userIdModules: artifactManifest.userIdModules, }; - pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.eval( + `window.__conflictingRequestBids=function conflictingRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__conflictingRequestBids:function(){};});` + ); pageWindow.eval( `window.__conflictingStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify(conflictingStamp)});` ); @@ -167,12 +169,132 @@ describe('external bundle + served shim evaluated together', () => { expect(() => pageWindow.eval(bundleCode)).not.toThrow(); expect(pageWindow.pbjs).toBe(binding); - expect(pageWindow.pbjs.requestBids).toBeUndefined(); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__conflictingRequestBids); expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__conflictingStamp); expect(warn).toHaveBeenCalledTimes(1); dom.window.close(); }); + it('does not mistake an exact stamp on a Prebid stub for an initialized duplicate', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + pageWindow.eval( + `window.__exactStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify( + { + abi: artifactManifest.abi, + artifactReleaseId: artifactManifest.artifactReleaseId, + prebidVersion: artifactManifest.prebidVersion, + moduleStems: artifactManifest.moduleStems, + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + } + )});` + ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__exactStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__exactStamp); + dom.window.close(); + }); + + it('accepts an exact 128-byte non-ASCII artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const boundaryName = 'é'.repeat(64); + const boundaryStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'e'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, boundaryName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` + ); + pageWindow.eval( + `window.__boundaryStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify( + boundaryStamp + )});` + ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__boundaryStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(pageWindow.pbjs.requestBids).toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__boundaryStamp); + dom.window.close(); + }); + + it('does not accept a UTF-8-overlong artifact name on a real stamped binding', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + const overlongName = `${'é'.repeat(64)}a`; + const malformedStamp = { + abi: artifactManifest.abi, + artifactReleaseId: 'f'.repeat(64), + prebidVersion: artifactManifest.prebidVersion, + moduleStems: [...artifactManifest.moduleStems, overlongName].sort(), + bidderCodes: artifactManifest.bidderCodes, + bidderAliases: artifactManifest.bidderAliases, + userIdModules: artifactManifest.userIdModules, + }; + pageWindow.eval( + `window.__fakeRequestBids=function fakeRequestBids(){};window.pbjs={que:[],cmd:[]};["addAdUnits","getBidResponsesForAdUnitCode","getHighestCpmBids","offEvent","onEvent","processQueue","registerBidAdapter","renderAd","requestBids","setTargetingForGPTAsync"].forEach(function(name){window.pbjs[name]=name==="requestBids"?window.__fakeRequestBids:function(){};});` + ); + pageWindow.eval( + `window.__malformedStamp=(function freeze(value){if(value&&typeof value==='object'){Object.getOwnPropertyNames(value).forEach(function(key){freeze(value[key]);});Object.freeze(value);}return value;})(${JSON.stringify( + malformedStamp + )});` + ); + pageWindow.Object.defineProperty(pageWindow.pbjs, '__trustedServerArtifactV1', { + value: pageWindow.__malformedStamp, + enumerable: false, + writable: false, + configurable: false, + }); + + expect(() => pageWindow.eval(bundleCode)).not.toThrow(); + expect(typeof pageWindow.pbjs.requestBids).toBe('function'); + expect(pageWindow.pbjs.requestBids).not.toBe(pageWindow.__fakeRequestBids); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(pageWindow.__malformedStamp); + dom.window.close(); + }); + it('keeps publisher Prebid usable when a hostile stamp cannot be replaced', () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', From 548045d2ac56d47ce7a7ed7513969e8d68630ad9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:43:26 -0700 Subject: [PATCH 375/494] Prune render trace state with navigation ownership --- .../lib/src/composition/browser.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 20f82162e..f265a2f23 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -352,6 +352,7 @@ export function createTestBrowserRuntimeComposition( let gptDiagnosticsFacts: GptDiagnosticsFactBuffer | undefined; let gptDiagnosticsRuntime: GptDiagnosticsRuntime | undefined; let renderTrace: RenderTraceRuntimeOwner | undefined; + const renderTraceSlotsByNavigation = new Map>(); let acceptedBrowserBoot: AcceptedBrowserBoot | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { if ( @@ -398,6 +399,12 @@ export function createTestBrowserRuntimeComposition( const adId = optionalString('adId'); const bidId = optionalString('bidId'); const creativeId = optionalString('creativeId'); + const navigation = runtimeSession?.currentNavigation; + if (navigation?.isCurrent()) { + const tracedSlots = renderTraceSlotsByNavigation.get(navigation.generation) ?? new Set(); + tracedSlots.add(slotId); + renderTraceSlotsByNavigation.set(navigation.generation, tracedSlots); + } renderTrace?.record({ slotId, path: observation['path'], @@ -1020,8 +1027,14 @@ export function createTestBrowserRuntimeComposition( testlight: testlightRuntime, ...services, }), - onNavigationDispose: (navigationGeneration) => - artifacts.disposeNavigation(navigationGeneration), + onNavigationDispose: (navigationGeneration) => { + artifacts.disposeNavigation(navigationGeneration); + for (const registeredSlotId of + renderTraceSlotsByNavigation.get(navigationGeneration) ?? []) { + preparedRenderTrace.prune(registeredSlotId); + } + renderTraceSlotsByNavigation.delete(navigationGeneration); + }, }); context.onDispose(() => { batchCoordinator.dispose(); @@ -1043,6 +1056,7 @@ export function createTestBrowserRuntimeComposition( acceptedBrowserBoot = undefined; creativeBoot = undefined; diagnosticsBoot = undefined; + renderTraceSlotsByNavigation.clear(); } }); const navigation = session.startInitialNavigation(initialProjection); From 8d3bb4d686ffd614f4ee88775ebc4f1db2cfba9b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:44:49 -0700 Subject: [PATCH 376/494] Satisfy strict consent timer initialization --- .../lib/src/integrations/osano/consent_mirror.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts index 7592bfadc..cb5b40516 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts @@ -207,7 +207,7 @@ function readUspSignal(win: OsanoWindow): Promise { if (typeof win.__uspapi !== 'function') return Promise.resolve(unavailableResult()); return new Promise((resolve) => { - let timer: number | undefined; + let timer: number | undefined = undefined; let cancelPending = (): void => undefined; const done = finishOnce((result: SignalResult) => { if (timer !== undefined) window.clearTimeout(timer); @@ -248,7 +248,7 @@ function readGppSignal(win: OsanoWindow): Promise { if (typeof win.__gpp !== 'function') return Promise.resolve(unavailableResult()); return new Promise((resolve) => { - let timer: number | undefined; + let timer: number | undefined = undefined; let cancelPending = (): void => undefined; const done = finishOnce((result: SignalResult) => { if (timer !== undefined) window.clearTimeout(timer); @@ -313,7 +313,7 @@ function readTcfSignal(win: OsanoWindow): Promise { if (typeof win.__tcfapi !== 'function') return Promise.resolve(unavailableResult()); return new Promise((resolve) => { - let timer: number | undefined; + let timer: number | undefined = undefined; let cancelPending = (): void => undefined; const done = finishOnce((result: SignalResult) => { if (timer !== undefined) window.clearTimeout(timer); From 4dbdd9c2d17e3e3f059cb614672cf7aad1b2b1d7 Mon Sep 17 00:00:00 2001 From: AG <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:31:21 -0700 Subject: [PATCH 377/494] Use string-form Cargo aliases so nested worktrees do not break them (#1004) Cargo discovers .cargo/config.toml in every ancestor directory, so a worktree nested inside the repo (e.g. .claude/worktrees/*) loads both the worktree's copy and the parent checkout's copy. Array-valued config keys merge by concatenation, expanding every alias to doubled tokens ("check ... check ...") and failing with: unexpected argument 'check'. String values are overridden by the deeper config instead of merged, and Cargo splits string aliases on whitespace, so behavior is otherwise identical. --- .cargo/config.toml | 56 +++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 93ec9484b..1302091e0 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,10 +9,16 @@ # default-members = [fastly] — required so Viceroy can locate the binary via `cargo run --bin`. # The aliases below are grouped by adapter; each targets its adapter with the # correct toolchain (build / check / test / clippy). +# +# Aliases use string form, not array form: in a worktree nested inside the +# repo (e.g. .claude/worktrees/*) Cargo discovers this file twice — the +# worktree's copy and the parent checkout's — and merges array values by +# concatenation, doubling every token ("check ... check ..."). +# String values are overridden by the deeper config instead of merged. [alias] # Generic: native test with an explicit host target. -test_details = ["test", "--target", "aarch64-apple-darwin"] +test_details = "test --target aarch64-apple-darwin" # --- Fastly adapter (wasm32-wasip1, run via Viceroy) --- # Whitelist the wasm-buildable crates (the Fastly adapter + the shared crates it @@ -20,43 +26,43 @@ test_details = ["test", "--target", "aarch64-apple-darwin"] # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = ["build", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--target", "wasm32-wasip1"] -check-fastly = ["check", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--target", "wasm32-wasip1"] -clippy-fastly = ["clippy", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--all-targets", "--all-features", "--target", "wasm32-wasip1", "--", "-D", "warnings"] -test-fastly = ["test", "-p", "trusted-server-core", "-p", "trusted-server-adapter-fastly", "-p", "trusted-server-js", "-p", "trusted-server-openrtb", "--target", "wasm32-wasip1"] +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- -build-axum = ["build", "-p", "trusted-server-adapter-axum"] -check-axum = ["check", "-p", "trusted-server-adapter-axum"] -clippy-axum = ["clippy", "-p", "trusted-server-adapter-axum", "--all-targets", "--all-features", "--", "-D", "warnings"] -test-axum = ["test", "-p", "trusted-server-adapter-axum"] +build-axum = "build -p trusted-server-adapter-axum" +check-axum = "check -p trusted-server-adapter-axum" +clippy-axum = "clippy -p trusted-server-adapter-axum --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); # tests run on the native host; clippy covers both native test code and the # production WASM feature. -build-cloudflare = ["build", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] -check-cloudflare = ["check", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare"] +build-cloudflare = "build -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare" +check-cloudflare = "check -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare" # No --all-features: the `cloudflare` feature has a compile_error! guard on # non-wasm32 targets. -clippy-cloudflare = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--all-targets", "--", "-D", "warnings"] -clippy-cloudflare-wasm = ["clippy", "-p", "trusted-server-adapter-cloudflare", "--target", "wasm32-unknown-unknown", "--features", "cloudflare", "--lib", "--", "-D", "warnings"] -test-cloudflare = ["test", "-p", "trusted-server-adapter-cloudflare"] +clippy-cloudflare = "clippy -p trusted-server-adapter-cloudflare --all-targets -- -D warnings" +clippy-cloudflare-wasm = "clippy -p trusted-server-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare --lib -- -D warnings" +test-cloudflare = "test -p trusted-server-adapter-cloudflare" # --- Spin adapter (native host tests + wasm32-wasip1 target) --- -check-spin = ["check", "-p", "trusted-server-adapter-spin", "--target", "wasm32-wasip1", "--features", "spin"] -clippy-spin-native = ["clippy", "-p", "trusted-server-adapter-spin", "--all-targets", "--", "-D", "warnings"] -clippy-spin-wasm = ["clippy", "-p", "trusted-server-adapter-spin", "--target", "wasm32-wasip1", "--features", "spin", "--lib", "--", "-D", "warnings"] -test-spin = ["test", "-p", "trusted-server-adapter-spin"] +check-spin = "check -p trusted-server-adapter-spin --target wasm32-wasip1 --features spin" +clippy-spin-native = "clippy -p trusted-server-adapter-spin --all-targets -- -D warnings" +clippy-spin-wasm = "clippy -p trusted-server-adapter-spin --target wasm32-wasip1 --features spin --lib -- -D warnings" +test-spin = "test -p trusted-server-adapter-spin" # --- ts operator CLI (native host; install uses the current host platform) --- -install-cli = ["install", "--path", "crates/trusted-server-cli", "--bin", "ts", "--locked", "--force"] -build_cli_linux = ["build", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"] -build_cli_macos = ["build", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] -run_cli_linux = ["run", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu", "--"] -run_cli_macos = ["run", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin", "--"] -test_cli_linux = ["test", "--package", "trusted-server-cli", "--target", "x86_64-unknown-linux-gnu"] -test_cli_macos = ["test", "--package", "trusted-server-cli", "--target", "aarch64-apple-darwin"] +install-cli = "install --path crates/trusted-server-cli --bin ts --locked --force" +build_cli_linux = "build --package trusted-server-cli --target x86_64-unknown-linux-gnu" +build_cli_macos = "build --package trusted-server-cli --target aarch64-apple-darwin" +run_cli_linux = "run --package trusted-server-cli --target x86_64-unknown-linux-gnu --" +run_cli_macos = "run --package trusted-server-cli --target aarch64-apple-darwin --" +test_cli_linux = "test --package trusted-server-cli --target x86_64-unknown-linux-gnu" +test_cli_macos = "test --package trusted-server-cli --target aarch64-apple-darwin" # When a wasm binary IS built, run it under Viceroy. [target.'cfg(all(target_arch = "wasm32"))'] From 38897a3e85693646fedda017bb177f83264b7b81 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:50:14 -0700 Subject: [PATCH 378/494] Compose the Prebid refresh policy --- .../lib/src/composition/browser.ts | 104 ++++++++++++ .../lib/src/integrations/prebid/module.ts | 122 ++++++++++++++ .../lib/test/composition/browser.test.ts | 157 +++++++++++++++++- 3 files changed, 380 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index f265a2f23..9f5e4420d 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -71,7 +71,10 @@ import { type GptDiagnosticsRuntime, } from '../integrations/gpt_diagnostics'; import { + createPrebidRefreshPolicy, createPrebidSelectionCoordinator, + createPrebidSyntheticRefreshRunner, + preparePrebidRegisteredRefreshAuction, publishPrebidBid, type PrebidSelectionCoordinator, } from '../integrations/prebid/module'; @@ -244,6 +247,85 @@ function projectionSlots(projection: object): readonly string[] { return Object.freeze(accepted.auction.results.map(({ slot }) => slot)); } +interface ComposedPrebidRefreshConfig { + readonly clientSideBidders: readonly string[]; + readonly excludedGamAdUnitPathSuffixes: readonly string[]; +} + +const EMPTY_PREBID_REFRESH_CONFIG: ComposedPrebidRefreshConfig = Object.freeze({ + clientSideBidders: Object.freeze([]), + excludedGamAdUnitPathSuffixes: Object.freeze([]), +}); + +function composedPrebidRefreshConfig(candidate: unknown): ComposedPrebidRefreshConfig { + try { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return EMPTY_PREBID_REFRESH_CONFIG; + } + const strings = (name: string): readonly string[] => { + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + if (!descriptor || !('value' in descriptor) || !Array.isArray(descriptor.value)) { + return Object.freeze([]); + } + const values: string[] = []; + for (let index = 0; index < descriptor.value.length; index += 1) { + const value = descriptor.value[index]; + if (typeof value !== 'string') return Object.freeze([]); + values.push(value); + } + return Object.freeze(values); + }; + return Object.freeze({ + clientSideBidders: strings('clientSideBidders'), + excludedGamAdUnitPathSuffixes: strings('excludedGamAdUnitPathSuffixes'), + }); + } catch { + return EMPTY_PREBID_REFRESH_CONFIG; + } +} + +function composedPrebidRefreshAuction( + physicalSlots: readonly object[], + navigation: RuntimeSession['currentNavigation'], + slots: SlotService, + config: ComposedPrebidRefreshConfig +): unknown { + if (!navigation?.isCurrent()) return undefined; + const records = slots.snapshotRegisteredSlots(navigation); + if (!records) return undefined; + const resolved = new Map>(); + for (let slotIndex = 0; slotIndex < physicalSlots.length; slotIndex += 1) { + const physicalSlot = physicalSlots[slotIndex]; + if (!physicalSlot) return undefined; + let matched: SlotRecord | undefined; + for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) { + const record = records[recordIndex]; + if ( + !record || + !slots.isBoundGptSlot( + navigation.generation, + record.registeredSlotId, + physicalSlot + ) + ) { + continue; + } + if (matched) return undefined; + matched = record; + } + const source = matched?.directAuctionUnit; + if (!source || !Object.isFrozen(source)) { + return undefined; + } + resolved.set(physicalSlot, source); + } + return preparePrebidRegisteredRefreshAuction({ + clientSideBidders: config.clientSideBidders, + resolveAdUnit: (slot) => resolved.get(slot), + slots: physicalSlots, + }); +} + function registerScopedContextContributor( registry: AuctionContextRegistry, runtimeOwner: RuntimeSession, @@ -463,7 +545,22 @@ export function createTestBrowserRuntimeComposition( }); let runtimeSession: RuntimeSession | undefined; let prebidCoordinator: PrebidSelectionCoordinator | undefined; + let prebidRefreshConfig = EMPTY_PREBID_REFRESH_CONFIG; const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); + const prebidRefreshRunner = createPrebidSyntheticRefreshRunner({ + prebid: composition.adapters.prebid, + prepareAuction: (slots, navigation) => { + const slotService = browserServices?.slots; + if (!slotService) return undefined; + return composedPrebidRefreshAuction(slots, navigation, slotService, prebidRefreshConfig); + }, + }); + const prebidRefreshPolicy = createPrebidRefreshPolicy({ + currentNavigation: () => runtimeSession?.currentNavigation, + excludedGamAdUnitPathSuffixes: () => prebidRefreshConfig.excludedGamAdUnitPathSuffixes, + googletag: composition.adapters.googletag, + runSyntheticAuction: prebidRefreshRunner, + }); const completePrebidAuction = (auction: Readonly): void => { try { auction.complete(); @@ -530,6 +627,13 @@ export function createTestBrowserRuntimeComposition( onAuction: publishPrebidAuction, onAuctionEnd: (event, prebid) => prebidCoordinator?.auctionEnded(event, prebid), prebid: composition.adapters.prebid, + refresh: Object.freeze({ + configure: (config: unknown): void => { + prebidRefreshConfig = composedPrebidRefreshConfig(config); + }, + install: gptRuntime.installRefreshPolicy, + policy: prebidRefreshPolicy, + }), start: startPrebid, }); const getBindings: NonNullable = (id) => { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 9efe63408..25e78f19d 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -1,5 +1,6 @@ import { isRendererReservationIdV1, + ownDataArray, ownDataObject, validBoundedString, validDimension, @@ -35,6 +36,7 @@ import type { import type { ReservationService } from '../../services/reservations'; export const PREBID_INTEGRATION_ID = 'prebid' as const; +const TRUSTED_SERVER_PREBID_BIDDER = 'trustedServer'; export type { PreparedTrustedBidV1 } from '../../adapters/prebid'; const MAX_CONFIG_DEPTH = 16; @@ -192,6 +194,12 @@ export interface PrebidRefreshAuctionPreparation { readonly adUnits: readonly object[]; } +export interface PrebidRegisteredRefreshAuctionOptions { + readonly clientSideBidders: readonly string[]; + readonly resolveAdUnit: (slot: object) => unknown; + readonly slots: readonly object[]; +} + export interface PrebidRefreshAuctionOperation { readonly completion: Promise; readonly dispose: () => void; @@ -272,6 +280,120 @@ function validRefreshAuctionPreparation( } } +function defineDataProperty(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); +} + +/** + * Rebuild synthetic Prebid units from detached runtime-owned registrations. + * + * The composition root resolves physical GPT identities to registered units; + * this integration-owned boundary performs all bidder routing without reading + * mutable `pbjs.adUnits` publisher state. + */ +export function preparePrebidRegisteredRefreshAuction( + options: PrebidRegisteredRefreshAuctionOptions +): PrebidRefreshAuctionPreparation | undefined { + try { + if ( + options.slots.length === 0 || + options.slots.length > MAX_PREBID_REFRESH_AD_UNITS || + !Object.isFrozen(options.slots) + ) { + return undefined; + } + const clientSideBidders = new Set(); + for (let index = 0; index < options.clientSideBidders.length; index += 1) { + const bidder = options.clientSideBidders[index]; + if (!validBoundedString(bidder, 64)) return undefined; + clientSideBidders.add(bidder); + } + + const adUnitCodes: string[] = []; + const adUnits: object[] = []; + const seenCodes = new Set(); + for (let slotIndex = 0; slotIndex < options.slots.length; slotIndex += 1) { + const slot = options.slots[slotIndex]; + if (!slot) return undefined; + const source = ownDataObject(options.resolveAdUnit(slot)); + if (!source || !validBoundedString(source.code, 128) || seenCodes.has(source.code)) { + return undefined; + } + const mediaTypes = ownDataObject(source.mediaTypes); + if (!mediaTypes || !Object.isFrozen(source.mediaTypes)) return undefined; + const rawBids = + source.bids === undefined + ? [] + : ownDataArray(source.bids, MAX_CONFIG_MEMBERS); + if (!rawBids || (source.bids !== undefined && !Object.isFrozen(source.bids))) { + return undefined; + } + + const bidderParams: Record = {}; + const trustedParams: Record = {}; + const clientBids: object[] = []; + let foundTrustedBid = false; + for (let bidIndex = 0; bidIndex < rawBids.length; bidIndex += 1) { + const bid = ownDataObject(rawBids[bidIndex]); + if (!bid || !validBoundedString(bid.bidder, 64)) return undefined; + const params = bid.params === undefined ? Object.freeze({}) : bid.params; + if (!ownDataObject(params) || !Object.isFrozen(params)) return undefined; + if (bid.bidder === TRUSTED_SERVER_PREBID_BIDDER) { + if (foundTrustedBid) return undefined; + foundTrustedBid = true; + const existingParams = ownDataObject(params); + if (!existingParams) return undefined; + for (const [key, value] of Object.entries(existingParams)) { + if (key !== 'bidderParams') defineDataProperty(trustedParams, key, value); + } + const folded = existingParams['bidderParams']; + if (folded !== undefined) { + const foldedRecord = ownDataObject(folded); + if (!foldedRecord || !Object.isFrozen(folded)) return undefined; + for (const [bidder, bidderValue] of Object.entries(foldedRecord)) { + if (!validBoundedString(bidder, 64) || !ownDataObject(bidderValue)) return undefined; + defineDataProperty(bidderParams, bidder, bidderValue); + } + } + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientBids.push(Object.freeze({ bidder: bid.bidder, params })); + continue; + } + defineDataProperty(bidderParams, bid.bidder, params); + } + + defineDataProperty(trustedParams, 'bidderParams', Object.freeze(bidderParams)); + const synthetic = Object.freeze({ + code: source.code, + mediaTypes: source.mediaTypes, + bids: Object.freeze([ + Object.freeze({ + bidder: TRUSTED_SERVER_PREBID_BIDDER, + params: Object.freeze(trustedParams), + }), + ...clientBids, + ]), + }); + seenCodes.add(source.code); + adUnitCodes.push(source.code); + adUnits.push(synthetic); + } + return Object.freeze({ + adUnitCodes: Object.freeze(adUnitCodes), + adUnits: Object.freeze(adUnits), + }); + } catch { + return undefined; + } +} + /** Run one synthetic refresh auction through the exact current Prebid adapter binding. */ export function createPrebidSyntheticRefreshRunner( options: PrebidSyntheticRefreshRunnerOptions diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 8d255d48d..dbc3a222c 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -7,6 +7,8 @@ import { type GoogletagBindingStatus, type GoogletagDiagnosticsObserver, type GoogletagFacade, + type GoogletagPublisherCallObserver, + type GoogletagPublisherRefreshCall, } from '../../src/adapters/googletag'; import { createBrowserMessagingAdapter, @@ -75,7 +77,12 @@ function synchronousGptAdapter() { const bindingToken = Object.freeze({}); const refresh = vi.fn(); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; + let publisherObserver: GoogletagPublisherCallObserver | undefined; const facade: GoogletagFacade = Object.freeze({ + adUnitPath: (slot: object) => + 'getAdUnitPath' in slot && typeof slot.getAdUnitPath === 'function' + ? slot.getAdUnitPath() + : undefined, bindingToken: () => bindingToken, clearTargeting: vi.fn((slot: object, key?: string) => { const values = targeting.get(slot); @@ -115,7 +122,12 @@ function synchronousGptAdapter() { if (diagnosticsObserver === observer) diagnosticsObserver = undefined; }; }, - observePublisherCalls: () => vi.fn(), + observePublisherCalls: (observer: GoogletagPublisherCallObserver) => { + publisherObserver = observer; + return () => { + if (publisherObserver === observer) publisherObserver = undefined; + }; + }, run: (command: (gpt: Readonly) => Value) => { let result: Promise; try { @@ -148,6 +160,11 @@ function synchronousGptAdapter() { .filter(([, registered]) => registered.size > 0) .map(([eventType, registered]) => Object.freeze([eventType, registered.size] as const)) ), + publisherRefresh: (call: Readonly) => { + const observer = publisherObserver; + if (!observer?.refresh) throw new Error('Publisher observer is unavailable'); + return observer.refresh(call); + }, refresh, }; } @@ -167,6 +184,8 @@ function synchronousPrebidAdapter() { admitted = prepared; return 'admitted' as const; }); + const requestBids = vi.fn(); + const setTargetingForGpt = vi.fn(); const facade = Object.freeze({ addAdUnits: vi.fn(), highestBids: vi.fn(() => Object.freeze([])), @@ -181,8 +200,8 @@ function synchronousPrebidAdapter() { } ), renderAd: vi.fn(), - requestBids: vi.fn(), - setTargetingForGpt: vi.fn(), + requestBids, + setTargetingForGpt, subscribe: vi.fn( ( eventType: string, @@ -229,6 +248,8 @@ function synchronousPrebidAdapter() { Object.freeze({ highestBids: () => highest }) ); }, + requestBids, + setTargetingForGpt, }; } @@ -1000,6 +1021,136 @@ describe('browser composition', () => { expect(isGuardInstalled()).toBe(false); }); + it('composes the configured Prebid refresh policy through the owned GPT boundary', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const prebid = synchronousPrebidAdapter(); + const prebidConfig = Object.freeze({ + clientSideBidders: Object.freeze(['client']), + excludedGamAdUnitPathSuffixes: Object.freeze([]), + }); + let request: + | Readonly<{ + adUnits: readonly object[]; + bidsBackHandler: () => void; + timeout: number; + }> + | undefined; + prebid.requestBids.mockImplementation((candidate: unknown) => { + request = candidate as typeof request; + request?.bidsBackHandler(); + }); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [ + { id: 'gpt', required: true }, + { id: 'prebid', required: true }, + ], + }, + knownIntegrationIds: Object.freeze(['gpt', 'prebid']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: (id) => ({ + config: id === 'prebid' ? prebidConfig : Object.freeze({}), + interfaces: Object.freeze({}), + }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: prebid.adapter, + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + expect( + composition.runtime.registerIntegration(createPrebidIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const api = target as { + addAdUnits(unit: unknown): Readonly<{ registered: readonly string[] }>; + }; + expect( + api.addAdUnits({ + code: 'refresh-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { bidder: 'server', params: { placement: 7 } }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }) + ).toEqual({ registered: ['refresh-slot'] }); + const navigation = composition.runtimeSessionForTest()?.currentNavigation; + const slots = composition.slotServiceForTest(); + const physicalSlot = Object.freeze({ getAdUnitPath: () => '/network/refresh-slot' }); + if (!navigation || !slots) throw new Error('Expected the active refresh composition'); + expect( + slots.adoptGptSlot(navigation.generation, 'refresh-slot', { + definition: { + adUnitPath: '/network/refresh-slot', + elementId: 'refresh-slot', + sizes: Object.freeze([[300, 250]]), + }, + ownership: 'publisher', + slot: physicalSlot, + }) + ).toEqual({ ok: true }); + + const refreshOptions = Object.freeze({ changeCorrelator: false }); + const decision = gpt.publisherRefresh( + Object.freeze({ + requestedSlots: Object.freeze([physicalSlot]), + slots: Object.freeze([physicalSlot]), + options: refreshOptions, + }) + ); + expect(decision).toMatchObject({ + action: 'defer', + slots: [physicalSlot], + completion: expect.any(Promise), + }); + if (decision?.action !== 'defer') throw new Error('Expected the composed refresh policy'); + await decision.completion; + + expect(request?.timeout).toBe(1_500); + expect(request?.adUnits).toEqual([ + { + code: 'refresh-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { server: { placement: 7 } } }, + }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }, + ]); + expect(prebid.setTargetingForGpt).toHaveBeenCalledExactlyOnceWith(['refresh-slot']); + composition.runtime.dispose(); + }); + it('owns every remaining integration in one maximal composed transaction', async () => { vi.useFakeTimers(); const releaseId = 'a'.repeat(64); From fc27df98a47ac2514545f40166cedd61c8bd2179 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:51:59 -0700 Subject: [PATCH 379/494] Harden synthetic Prebid refresh routing --- .../lib/src/integrations/prebid/module.ts | 10 ++- .../test/integrations/prebid/module.test.ts | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 25e78f19d..2220d39e0 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -334,7 +334,7 @@ export function preparePrebidRegisteredRefreshAuction( return undefined; } - const bidderParams: Record = {}; + const bidderParamEntries = new Map(); const trustedParams: Record = {}; const clientBids: object[] = []; let foundTrustedBid = false; @@ -357,7 +357,7 @@ export function preparePrebidRegisteredRefreshAuction( if (!foldedRecord || !Object.isFrozen(folded)) return undefined; for (const [bidder, bidderValue] of Object.entries(foldedRecord)) { if (!validBoundedString(bidder, 64) || !ownDataObject(bidderValue)) return undefined; - defineDataProperty(bidderParams, bidder, bidderValue); + if (!clientSideBidders.has(bidder)) bidderParamEntries.set(bidder, bidderValue); } } continue; @@ -366,9 +366,13 @@ export function preparePrebidRegisteredRefreshAuction( clientBids.push(Object.freeze({ bidder: bid.bidder, params })); continue; } - defineDataProperty(bidderParams, bid.bidder, params); + bidderParamEntries.set(bid.bidder, params); } + const bidderParams: Record = {}; + for (const [bidder, params] of bidderParamEntries) { + defineDataProperty(bidderParams, bidder, params); + } defineDataProperty(trustedParams, 'bidderParams', Object.freeze(bidderParams)); const synthetic = Object.freeze({ code: source.code, diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index ebec85968..611e5bbd8 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -11,6 +11,7 @@ import { createPrebidSelectionCoordinator, createPrebidSyntheticRefreshRunner, createPrebidIntegrationRegistration, + preparePrebidRegisteredRefreshAuction, publishPrebidBid, type PrebidBidPublicationInput, type PreparedTrustedBidV1, @@ -485,6 +486,75 @@ describe('RCJ-PREBID-04 prospective refresh policy', () => { }); describe('RCJ-PREBID-04 adapter-backed synthetic refresh runner', () => { + it('routes detached server and client bids without consulting publisher Prebid state', () => { + const slot = Object.freeze({ id: 'slot-a' }); + const serverParams = Object.freeze({ placement: 'current' }); + const unit = Object.freeze({ + code: 'slot-a', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + client: Object.freeze({ stale: true }), + preserved: Object.freeze({ placement: 'folded' }), + server: Object.freeze({ placement: 'stale' }), + }), + zone: 'news', + }), + }), + Object.freeze({ bidder: 'server', params: serverParams }), + Object.freeze({ bidder: 'client', params: Object.freeze({ placement: 'browser' }) }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze(['client']), + resolveAdUnit: (candidate) => (candidate === slot ? unit : undefined), + slots: Object.freeze([slot]), + }); + + expect(prepared).toEqual({ + adUnitCodes: ['slot-a'], + adUnits: [ + { + code: 'slot-a', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + preserved: { placement: 'folded' }, + server: { placement: 'current' }, + }, + zone: 'news', + }, + }, + { bidder: 'client', params: { placement: 'browser' } }, + ], + }, + ], + }); + expect(Object.isFrozen(prepared?.adUnits)).toBe(true); + expect(Object.isFrozen(prepared?.adUnits[0])).toBe(true); + }); + + it('fails closed when a physical slot has no detached registered ad unit', () => { + const slot = Object.freeze({ id: 'unregistered' }); + + expect( + preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => undefined, + slots: Object.freeze([slot]), + }) + ).toBeUndefined(); + }); + function runnerHarness(options: Readonly<{ requestThrows?: boolean }> = {}) { const runtime = createRuntimeSession({ createIdentityIssuer: () => From 6e0c068b0e748b58201c29e047fe2c8a2de4bc85 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:54:00 -0700 Subject: [PATCH 380/494] Test synthetic Prebid refresh boundaries --- .../test/integrations/prebid/module.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index 611e5bbd8..c7ac910dc 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -543,6 +543,157 @@ describe('RCJ-PREBID-04 adapter-backed synthetic refresh runner', () => { expect(Object.isFrozen(prepared?.adUnits[0])).toBe(true); }); + it('preserves legacy last-write precedence when folded params follow direct bids', () => { + const slot = Object.freeze({ id: 'slot-order' }); + const unit = Object.freeze({ + code: 'slot-order', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'server', + params: Object.freeze({ placement: 'direct-first' }), + }), + Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ + bidderParams: Object.freeze({ + preserved: Object.freeze({ placement: 'folded-only' }), + server: Object.freeze({ placement: 'folded-last' }), + }), + }), + }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + + expect(prepared?.adUnits).toEqual([ + { + code: 'slot-order', + mediaTypes: { banner: { sizes: [] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + server: { placement: 'folded-last' }, + preserved: { placement: 'folded-only' }, + }, + }, + }, + ], + }, + ]); + const bidderParams = ( + prepared?.adUnits[0] as { + bids: readonly [{ params: { bidderParams: Readonly> } }]; + } + ).bids[0].params.bidderParams; + expect(Object.keys(bidderParams)).toEqual(['server', 'preserved']); + }); + + it('fails closed when detached registrations contain duplicate trustedServer bids', () => { + const slot = Object.freeze({ id: 'slot-duplicate-trusted' }); + const trustedBid = Object.freeze({ + bidder: 'trustedServer', + params: Object.freeze({ bidderParams: Object.freeze({}) }), + }); + const unit = Object.freeze({ + code: 'slot-duplicate-trusted', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([trustedBid, trustedBid]), + }); + + expect( + preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }) + ).toBeUndefined(); + }); + + it('keeps deterministic order while resolving duplicate direct and client bids', () => { + const slot = Object.freeze({ id: 'slot-duplicates' }); + const unit = Object.freeze({ + code: 'slot-duplicates', + mediaTypes: Object.freeze({ banner: Object.freeze({ sizes: Object.freeze([]) }) }), + bids: Object.freeze([ + Object.freeze({ bidder: 'alpha', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'client', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'beta', params: Object.freeze({ sequence: 1 }) }), + Object.freeze({ bidder: 'alpha', params: Object.freeze({ sequence: 2 }) }), + Object.freeze({ bidder: 'client', params: Object.freeze({ sequence: 2 }) }), + ]), + }); + + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze(['client']), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + + expect(prepared?.adUnits).toEqual([ + { + code: 'slot-duplicates', + mediaTypes: { banner: { sizes: [] } }, + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { alpha: { sequence: 2 }, beta: { sequence: 1 } } }, + }, + { bidder: 'client', params: { sequence: 1 } }, + { bidder: 'client', params: { sequence: 2 } }, + ], + }, + ]); + const bidderParams = ( + prepared?.adUnits[0] as { + bids: readonly [{ params: { bidderParams: Readonly> } }]; + } + ).bids[0].params.bidderParams; + expect(Object.keys(bidderParams)).toEqual(['alpha', 'beta']); + }); + + it('returns a recursively frozen synthetic refresh preparation', () => { + const slot = Object.freeze({ id: 'slot-frozen' }); + const unit = Object.freeze({ + code: 'slot-frozen', + mediaTypes: Object.freeze({ + banner: Object.freeze({ sizes: Object.freeze([Object.freeze([300, 250])]) }), + }), + bids: Object.freeze([ + Object.freeze({ + bidder: 'server', + params: Object.freeze({ + placement: Object.freeze({ + rules: Object.freeze([Object.freeze({ label: 'frozen' })]), + }), + }), + }), + ]), + }); + const prepared = preparePrebidRegisteredRefreshAuction({ + clientSideBidders: Object.freeze([]), + resolveAdUnit: () => unit, + slots: Object.freeze([slot]), + }); + const seen = new Set(); + const expectRecursivelyFrozen = (value: unknown): void => { + if (value === null || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) expectRecursivelyFrozen(child); + }; + + expect(prepared).toBeDefined(); + expectRecursivelyFrozen(prepared); + }); + it('fails closed when a physical slot has no detached registered ad unit', () => { const slot = Object.freeze({ id: 'unregistered' }); From be5a7a2d30974212485f2ed537ad0dc6e5759c13 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:56:05 -0700 Subject: [PATCH 381/494] Format Prebid refresh composition --- .../trusted-server-js/lib/src/composition/browser.ts | 11 +++-------- .../lib/src/integrations/prebid/module.ts | 4 +--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 9f5e4420d..3cdc9d892 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -302,11 +302,7 @@ function composedPrebidRefreshAuction( const record = records[recordIndex]; if ( !record || - !slots.isBoundGptSlot( - navigation.generation, - record.registeredSlotId, - physicalSlot - ) + !slots.isBoundGptSlot(navigation.generation, record.registeredSlotId, physicalSlot) ) { continue; } @@ -1030,7 +1026,6 @@ export function createTestBrowserRuntimeComposition( cachePolicy, fetcher: (input, init) => fetchCache(input, init), onResolved, - publisherOrigin, }); } catch { return false; @@ -1133,8 +1128,8 @@ export function createTestBrowserRuntimeComposition( }), onNavigationDispose: (navigationGeneration) => { artifacts.disposeNavigation(navigationGeneration); - for (const registeredSlotId of - renderTraceSlotsByNavigation.get(navigationGeneration) ?? []) { + for (const registeredSlotId of renderTraceSlotsByNavigation.get(navigationGeneration) ?? + []) { preparedRenderTrace.prune(registeredSlotId); } renderTraceSlotsByNavigation.delete(navigationGeneration); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 2220d39e0..196ac4566 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -327,9 +327,7 @@ export function preparePrebidRegisteredRefreshAuction( const mediaTypes = ownDataObject(source.mediaTypes); if (!mediaTypes || !Object.isFrozen(source.mediaTypes)) return undefined; const rawBids = - source.bids === undefined - ? [] - : ownDataArray(source.bids, MAX_CONFIG_MEMBERS); + source.bids === undefined ? [] : ownDataArray(source.bids, MAX_CONFIG_MEMBERS); if (!rawBids || (source.bids !== undefined && !Object.isFrozen(source.bids))) { return undefined; } From 753933ea3c181ba1a35eacf62f674f6b986c9eb1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:56:48 -0700 Subject: [PATCH 382/494] Require CORS for cache creative fetches --- .../lib/src/services/render.ts | 17 ++-------- .../lib/test/services/render.test.ts | 33 ++++++++----------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 5d9c059c8..06ce5c5d4 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -733,7 +733,6 @@ export interface CacheAdmResolutionOptions { readonly cachePolicy: Readonly; readonly fetcher: CacheFetcher; readonly onResolved: (source: Readonly) => boolean; - readonly publisherOrigin: string; } export interface DirectCacheAttemptOptions extends DirectAdmAttemptOptions { @@ -2508,13 +2507,11 @@ export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): bool let cachePolicy: CacheAdmResolutionOptions['cachePolicy']; let fetchCache: CacheAdmResolutionOptions['fetcher']; let onResolved: CacheAdmResolutionOptions['onResolved']; - let publisherOrigin: string; try { attempt = options.attempt; cachePolicy = options.cachePolicy; fetchCache = options.fetcher; onResolved = options.onResolved; - publisherOrigin = options.publisherOrigin; } catch { return false; } @@ -2531,15 +2528,7 @@ export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): bool const source = readDirectCacheSource(attempt.renderSource, cachePolicy); const winnerContext = attempt.winnerContext; const selectedCpm = readSelectedCpm(winnerContext); - let cacheOrigin: string | undefined; - try { - cacheOrigin = source - ? urlPart(Reflect.construct(urlIntrinsic, [source.fetchUrl]) as URL, 'origin') - : undefined; - } catch { - cacheOrigin = undefined; - } - if (!source || selectedCpm === undefined || cacheOrigin === undefined) { + if (!source || selectedCpm === undefined) { attempt.fail('descriptor_invalid'); return false; } @@ -2674,8 +2663,7 @@ export function resolveCacheAdmAttempt(options: CacheAdmResolutionOptions): bool failCache('cache_network_error'); return; } - const expectedResponseType = cacheOrigin === publisherOrigin ? 'basic' : 'cors'; - if (responseType !== expectedResponseType) { + if (responseType !== 'cors') { failCache('cache_network_error'); return; } @@ -2784,7 +2772,6 @@ export function renderDirectCacheAttempt(options: DirectCacheAttemptOptions): bo fetcher, onResolved: (source) => renderAdmAttempt({ attempt, container, prepareIframe, publisherOrigin }, source), - publisherOrigin, }); } diff --git a/crates/trusted-server-js/lib/test/services/render.test.ts b/crates/trusted-server-js/lib/test/services/render.test.ts index b08aee273..ff9893538 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -2157,7 +2157,7 @@ describe('direct cache attempt rendering', () => { }); it.each(['basic', 'default', undefined] as const)( - 'rejects a cross-origin response with non-CORS type %s', + 'rejects every response with non-CORS type %s', async (responseType) => { document.body.innerHTML = '
'; const render = attempt(); @@ -2188,7 +2188,7 @@ describe('direct cache attempt rendering', () => { } ); - it('accepts a basic response only when the cache and publisher origins match', async () => { + it('rejects a basic response even when the cache and publisher origins match', async () => { const render = attempt(); expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); const response = new Response(JSON.stringify({ adm: '
same origin
' })); @@ -2201,16 +2201,18 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher: async () => response, onResolved, - publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, }) ).toBe(true); - await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); - expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
same origin
' }); - expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); - expect(render.cancel('caller_aborted')).toBe(true); + await vi.waitFor(() => + expect(render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'cache_network_error', + }) + ); + expect(onResolved).not.toHaveBeenCalled(); }); - it('rejects a CORS response when the cache and publisher origins match', async () => { + it('accepts a CORS response when the cache and publisher origins match', async () => { const render = attempt(); expect(render.admitDirectWinner(CACHE_SOURCE, WINNER_CONTEXT)).toBe(true); const onResolved = vi.fn<(source: CacheAdmSource) => boolean>(() => true); @@ -2221,16 +2223,12 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher: async () => corsResponse(JSON.stringify({ adm: '
wrong type
' })), onResolved, - publisherOrigin: new URL(CACHE_SOURCE.fetchUrl).origin, }) ).toBe(true); - await vi.waitFor(() => - expect(render.snapshot().outcome).toEqual({ - outcome: 'failed', - reason: 'cache_network_error', - }) - ); - expect(onResolved).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); + expect(onResolved.mock.calls[0]?.[0]).toMatchObject({ adm: '
wrong type
' }); + expect(render.snapshot()).toMatchObject({ outcome: undefined, state: 'rendering_direct' }); + expect(render.cancel('caller_aborted')).toBe(true); }); it('terminally rejects a foreign direct-cache container before fetching or mutating DOM', () => { @@ -2450,7 +2448,6 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher: fetchCache, onResolved, - publisherOrigin: window.location.origin, }) ).toBe(true); expect(render.snapshot()).toMatchObject({ @@ -2508,7 +2505,6 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher, onResolved, - publisherOrigin: window.location.origin, }) ).toBe(true); await vi.advanceTimersByTimeAsync(4_999); @@ -2606,7 +2602,6 @@ describe('direct cache attempt rendering', () => { cachePolicy: CACHE_POLICY, fetcher, onResolved, - publisherOrigin: window.location.origin, }) ).toBe(true); await vi.waitFor(() => expect(onResolved).toHaveBeenCalledOnce()); From 1c2a4549bfb4338d54196124b89e043adfdf5bec Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:00:39 -0700 Subject: [PATCH 383/494] Enforce Axum APS proxy transport deadlines --- .../src/platform.rs | 104 ++++++++++++++++-- scripts/integration-tests-aps-runner-proxy.sh | 12 +- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index c823fbc41..a44ceb147 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -482,9 +482,12 @@ impl AxumPlatformHttpClient { } tokio::time::timeout(policy.total_timeout, async move { - let mut response = builder - .send() + let mut response = tokio::time::timeout(policy.first_byte_timeout, builder.send()) .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte deadline exceeded") + })? .change_context(PlatformError::HttpClient)?; let evidence = ProxyResponseEvidenceV1 { status: response.status().as_u16(), @@ -509,11 +512,15 @@ impl AxumPlatformHttpClient { } let mut body = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .change_context(PlatformError::HttpClient)? - { + loop { + let chunk = tokio::time::timeout(policy.blocking_read_timeout, response.chunk()) + .await + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy blocking-read deadline exceeded") + })? + .change_context(PlatformError::HttpClient)?; + let Some(chunk) = chunk else { break }; let next_len = body.len().checked_add(chunk.len()).ok_or_else(|| { Report::new(PlatformError::HttpClient).attach("raw proxy body length overflow") })?; @@ -1048,6 +1055,89 @@ mod tests { assert!(deadline.is_err(), "total deadline must cover first byte"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn raw_proxy_enforces_first_byte_and_blocking_read_deadlines() { + let first_byte_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind first-byte deadline server"); + let first_byte_addr = first_byte_listener + .local_addr() + .expect("should read first-byte server address"); + tokio::spawn(async move { + let (mut stream, _) = first_byte_listener + .accept() + .await + .expect("should accept first-byte request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read first-byte request"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\n\r\nok", + ) + .await; + }); + let first_byte = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{first_byte_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_millis(20), + blocking_read_timeout: Duration::from_secs(1), + max_response_bytes: 2, + }, + ) + .await; + assert!( + first_byte.is_err(), + "response headers after the first-byte deadline must fail" + ); + + let body_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind blocking-read deadline server"); + let body_addr = body_listener + .local_addr() + .expect("should read blocking-read server address"); + tokio::spawn(async move { + let (mut stream, _) = body_listener + .accept() + .await + .expect("should accept blocking-read request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read blocking-read request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\no\r\n", + ) + .await + .expect("should write first body chunk"); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = stream.write_all(b"1\r\nk\r\n0\r\n\r\n").await; + }); + let blocking_read = AxumPlatformHttpClient::new() + .send_raw_proxy_v1( + raw_proxy_request(&format!("http://{body_addr}/")), + RawProxyPolicyV1 { + total_timeout: Duration::from_secs(1), + first_byte_timeout: Duration::from_secs(1), + blocking_read_timeout: Duration::from_millis(20), + max_response_bytes: 2, + }, + ) + .await; + assert!( + blocking_read.is_err(), + "a body read blocked past its deadline must fail" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn select_attributes_failed_backend_name() { // Bind and immediately drop a listener so the port is closed — the diff --git a/scripts/integration-tests-aps-runner-proxy.sh b/scripts/integration-tests-aps-runner-proxy.sh index ac653aa95..dd3144214 100755 --- a/scripts/integration-tests-aps-runner-proxy.sh +++ b/scripts/integration-tests-aps-runner-proxy.sh @@ -178,7 +178,17 @@ else fi CARGO_TEST_PID="$!" -CHILD_PGID="$(ps -o pgid= -p "$CARGO_TEST_PID" 2>/dev/null | tr -d '[:space:]' || true)" +CHILD_PGID="" +# The background child can be observed between fork and `setsid(2)`, especially +# when `setsid` is provided by a shim on BSD/macOS. Give it a bounded moment to +# enter its dedicated process group before enforcing the cleanup invariant. +for ((attempt = 0; attempt < 50; attempt += 1)); do + CHILD_PGID="$(ps -o pgid= -p "$CARGO_TEST_PID" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then + break + fi + sleep 0.01 +done if [[ "$CHILD_PGID" =~ ^[1-9][0-9]*$ ]] && [ "$CHILD_PGID" != "$SHELL_PGID" ]; then CARGO_TEST_PGID="$CHILD_PGID" else From f1a8ad8d28db8dd8c9606e215fd4938981047eb8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:07:08 -0700 Subject: [PATCH 384/494] Test APS deadline at the transport boundary --- .../tests/aps_runner_proxy.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs index feb2e5823..c038dc03a 100644 --- a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -22,6 +22,12 @@ const SUCCESS_HEADERS: [&str; 5] = [ "x-content-type-options", ]; +// The platform policy owns an exact five-second dispatch-through-final-byte +// deadline. This black-box clock additionally observes downstream request +// dispatch, local error serialization, and response delivery, so retain a +// bounded allowance for work outside the transport window. +const DOWNSTREAM_DEADLINE_OBSERVATION_ALLOWANCE: Duration = Duration::from_millis(250); + struct CorpusCase { name: &'static str, upstream: FictionalResponse, @@ -53,7 +59,9 @@ impl CorpusCase { fn deadline(name: &'static str, upstream: FictionalResponse) -> Self { Self { - maximum_elapsed: Some(Duration::from_secs(5)), + maximum_elapsed: Some( + Duration::from_secs(5) + DOWNSTREAM_DEADLINE_OBSERVATION_ALLOWANCE, + ), ..Self::failure(name, upstream) } } @@ -404,7 +412,8 @@ fn actual_adapter_proxy_corpus() { let client = Client::builder() .redirect(reqwest::redirect::Policy::none()) // This is only a downstream dead-test guard. Deadline corpus cases - // retain their independent, stricter five-second elapsed assertion. + // retain their independent five-second transport assertion plus the + // bounded black-box observation allowance above. // Leave enough headroom for an 8 MiB boundary response through local // wasm runtimes on a loaded CI worker. .timeout(Duration::from_secs(30)) From e9358f5f55278cc898fd49913ddb87b3311c0077 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:10:03 -0700 Subject: [PATCH 385/494] Settle failed Prebid publications --- .../lib/src/composition/browser.ts | 14 +- .../lib/src/integrations/prebid/module.ts | 57 ++++++ .../lib/test/composition/browser.test.ts | 166 +++++++++++++++++- 3 files changed, 233 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 3cdc9d892..7d426823a 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -591,7 +591,7 @@ export function createTestBrowserRuntimeComposition( if (bids.length !== 1) continue; const bid = bids[0]; if (!bid) continue; - publishPrebidBid({ + const publication = publishPrebidBid({ admitTrustedBid: (preparedBid) => composition.adapters.prebid.admitTrustedBid(preparedBid), auctionId: auction.auctionId, @@ -608,6 +608,18 @@ export function createTestBrowserRuntimeComposition( reservations, trackAdmittedBid: coordinator.track, }); + if ( + !publication.ok && + (publication.reason === 'prebid_admission_failed' || + publication.reason === 'prebid_contract_violation') + ) { + coordinator.settlePublicationFailure( + navigation, + auction.auctionId, + request.adUnitCode, + publication.reason + ); + } } } catch { // Invalid/stale projection state publishes no Prebid bid. diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts index 196ac4566..bf35e7773 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -748,6 +748,11 @@ export type PrebidBidPublicationResult = | Readonly<{ ok: true; bid: Readonly }> | Readonly<{ ok: false; reason: PrebidBidPublicationFailureReason }>; +export type PrebidPublicationLifecycleFailureReason = Extract< + PrebidBidPublicationFailureReason, + 'prebid_admission_failed' | 'prebid_contract_violation' +>; + type PrebidPublicationNavigation = NavigationSession; export interface PrebidBidPublicationInput { @@ -969,6 +974,12 @@ export interface PrebidSelectionCoordinator { navigation: NavigationSession ) => boolean; readonly auctionEnded: (event: unknown, prebid: Readonly) => void; + readonly settlePublicationFailure: ( + navigation: NavigationSession, + auctionId: string, + adUnitCode: string, + reason: PrebidPublicationLifecycleFailureReason + ) => boolean; readonly abort: (navigation: NavigationSession, auctionId: string) => void; readonly dispose: () => void; } @@ -1182,6 +1193,51 @@ export function createPrebidSelectionCoordinator( } }; + const settlePublicationFailure = ( + navigation: NavigationSession, + auctionId: string, + adUnitCode: string, + reason: PrebidPublicationLifecycleFailureReason + ): boolean => { + let ephemeralBatch: AuctionBatchScope | undefined; + try { + if ( + disposed || + !navigation.isCurrent() || + !validBoundedString(auctionId, 128) || + !validBoundedString(adUnitCode, 256) + ) { + return false; + } + const tracked = findAuction(navigation, auctionId); + const batch = tracked?.batch ?? navigation.createAuctionBatch(`prebid:${auctionId}`); + if (!batch) return false; + if (!tracked) ephemeralBatch = batch; + const owner = batch.createRenderAttempt(adUnitCode); + if (!owner.ok) return false; + let created: RenderAttemptCreationResult; + try { + created = options.createAttempt(owner.value); + } catch { + owner.value.dispose(); + return false; + } + if (!created.ok) { + owner.value.dispose(); + return false; + } + return created.value.fail(reason); + } catch { + return false; + } finally { + try { + ephemeralBatch?.dispose(); + } catch { + // The failed attempt is already terminal; navigation remains the final owner. + } + } + }; + const auctionEnded = (event: unknown, prebid: Readonly): void => { if (disposed) return; const record = ownDataObject(event); @@ -1302,6 +1358,7 @@ export function createPrebidSelectionCoordinator( return Object.freeze({ track, auctionEnded, + settlePublicationFailure, abort, dispose: (): void => { if (disposed) return; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index dbc3a222c..176c3136c 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -18,6 +18,7 @@ import { } from '../../src/adapters/messaging'; import { createNoopPrebidAdapter, + PrebidAdmissionContractError, type PrebidAdapter, type PrebidBindingStatus, type PrebidEventFacade, @@ -175,14 +176,18 @@ function fakePrebidAdapter( return Object.freeze({ ...createNoopPrebidAdapter(), bindingStatus }); } -function synchronousPrebidAdapter() { +function synchronousPrebidAdapter( + admission: (prepared: Readonly) => 'admitted' | 'not_admitted' = () => + 'admitted' +) { let auctionListener: ((auction: Readonly) => void) | undefined; let auctionEndListener: ((event: unknown, prebid: Readonly) => void) | undefined; let admitted: Readonly | undefined; const admitTrustedBid = vi.fn((prepared: Readonly) => { - admitted = prepared; - return 'admitted' as const; + const result = admission(prepared); + if (result === 'admitted') admitted = prepared; + return result; }); const requestBids = vi.fn(); const setTargetingForGpt = vi.fn(); @@ -1491,6 +1496,161 @@ describe('browser composition', () => { } }); + const prebidPublicationFailureCases: readonly (readonly [ + string, + (prepared: Readonly) => 'admitted' | 'not_admitted', + 'prebid_admission_failed' | 'prebid_contract_violation', + ])[] = [ + ['not admitted', () => 'not_admitted', 'prebid_admission_failed'], + [ + 'partial publication', + () => { + throw new PrebidAdmissionContractError(); + }, + 'prebid_contract_violation', + ], + ]; + it.each(prebidPublicationFailureCases)( + 'settles a %s Prebid publication as an exact slot lifecycle failure', + async (_case, admission, reason) => { + const releaseId = 'a'.repeat(64); + const prebid = synchronousPrebidAdapter(admission); + const reservationId = `r1_${'q'.repeat(22)}`; + const observations: Readonly>[] = []; + const bid = Object.freeze({ + candidateId: 'BBBBBBBBBBBB', + slot: 'failed-slot', + provider: 'trusted', + upstreamBidId: 'failed-upstream', + cpm: 2.5, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trustedServer' }), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
must not render
', + width: 300, + height: 250, + }), + }); + const projection = Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'failed-auction', + results: Object.freeze([ + Object.freeze({ + slot: bid.slot, + outcome: 'winner' as const, + candidateId: bid.candidateId, + }), + ]), + }), + bids: Object.freeze([bid]), + }); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [ + { id: 'prebid', required: true }, + { id: 'lifecycle_probe', required: true }, + ], + }, + knownIntegrationIds: Object.freeze(['prebid', 'lifecycle_probe']), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: prebid.adapter, + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createPrebidIntegrationRegistration(releaseId)) + ).toBe(true); + expect( + composition.runtime.registerIntegration({ + id: 'lifecycle_probe', + release: releaseId, + prepare: ({ + interfaces, + onDispose, + }: { + interfaces: Readonly>; + onDispose(callback: () => void): void; + }) => { + const diagnostics = interfaces['diagnostics'] as { + subscribe( + id: string, + listener: (observation: Readonly>) => void + ): (() => void) | undefined; + }; + const release = diagnostics.subscribe('lifecycle_probe', (observation) => + observations.push(observation) + ); + if (!release) throw new Error('Expected the lifecycle diagnostics subscription'); + onDispose(release); + return { activate: vi.fn() }; + }, + }) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const complete = vi.fn(); + + prebid.auction( + Object.freeze({ + auctionId: 'failed-auction', + bids: Object.freeze([ + Object.freeze({ adUnitCode: bid.slot, requestId: 'failed-request' }), + ]), + complete, + }) + ); + + expect(complete).toHaveBeenCalledOnce(); + expect(composition.reservationServiceForTest()?.recognize(reservationId)).toMatchObject({ + recognized: true, + state: reason, + }); + await vi.waitFor(() => + expect(observations).toContainEqual( + expect.objectContaining({ + kind: 'render_attempt', + slotId: bid.slot, + state: 'failed', + outcome: { outcome: 'failed', reason }, + }) + ) + ); + expect( + composition.runtimeSessionForTest()?.currentNavigation?.snapshotInventoryForTest() + ).toMatchObject({ + attempts: 0, + batches: 0, + }); + } finally { + composition.runtime.dispose(); + } + } + ); + it('hands late publisher GPT calls through the adapter into runtime-owned slot state', async () => { const releaseId = 'a'.repeat(64); const slot = Object.freeze({ id: 'trusted-slot' }); From 517c78f839a09162ef68ba5c2f96e23702911bfa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:13:08 -0700 Subject: [PATCH 386/494] Test hostile Prebid failure settlement --- .../test/integrations/prebid/module.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts index c7ac910dc..fbb84207c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/module.test.ts @@ -1089,6 +1089,7 @@ describe('Prebid selection coordination', () => { activateResult?: boolean; synchronousTimer?: boolean; throwCreateAttempt?: boolean; + throwFail?: boolean; throwPromotion?: boolean; }> = {} ) { @@ -1133,7 +1134,20 @@ describe('Prebid selection coordination', () => { : undefined, reservations, }); - if (result.ok) attempts.push(result.value); + if (result.ok) { + attempts.push(result.value); + if (options.throwFail) { + return Object.freeze({ + ok: true as const, + value: Object.freeze({ + ...result.value, + fail: () => { + throw new Error('attempt failure settlement failed'); + }, + }), + }); + } + } return result; }, reservations: { @@ -1221,6 +1235,28 @@ describe('Prebid selection coordination', () => { }; } + it('contains a hostile publication failure settlement and releases its ephemeral owner', () => { + const harness = prepareSelection({ throwFail: true }); + + expect( + harness.coordinator.settlePublicationFailure( + harness.navigation, + 'auction-one', + 'slot-one', + 'prebid_admission_failed' + ) + ).toBe(false); + expect(harness.attempts[0]?.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'navigation_disposed', + }); + expect(harness.navigation.snapshotInventoryForTest()).toMatchObject({ + attempts: 0, + batches: 0, + }); + harness.runtime.dispose(); + }); + it('promotes only the exact selected TS id and suppresses its group losers', () => { const harness = prepareSelection(); const selected = harness.admitted('a'); From faec1fcc0fc3bf89c42aa83c5c9a078e3fe06134 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:14:50 -0700 Subject: [PATCH 387/494] Route GPT diagnostics through the kernel bus --- .../lib/src/composition/browser.ts | 26 +++++- .../lib/test/composition/browser.test.ts | 88 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 7d426823a..daa503496 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -2,6 +2,7 @@ import { createBrowserGoogletagAdapter, createNoopGoogletagAdapter, type GoogletagAdapter, + type GoogletagDiagnosticsFact, type GoogletagGlobalTarget, } from '../adapters/googletag'; import { @@ -433,6 +434,23 @@ export function createTestBrowserRuntimeComposition( const renderTraceSlotsByNavigation = new Map>(); let acceptedBrowserBoot: AcceptedBrowserBoot | undefined; const consumeCoreObservation = (observation: DiagnosticsObservation): void => { + if ( + observation['kind'] === 'slotRequested' || + observation['kind'] === 'slotResponseReceived' || + observation['kind'] === 'slotRenderEnded' || + observation['kind'] === 'slotOnload' || + observation['kind'] === 'impressionViewable' || + observation['kind'] === 'slotVisibilityChanged' + ) { + try { + gptDiagnosticsFacts?.publish( + observation as unknown as Readonly + ); + } catch { + // GPT diagnostics never affect an already-committed adapter observation. + } + return; + } if ( observation['kind'] !== 'render_attempt' || typeof observation['slotId'] !== 'string' || @@ -1201,10 +1219,14 @@ export function createTestBrowserRuntimeComposition( const prepared = preparedBrowserServices; if (!prepared) throw new Error('Browser services are unavailable'); const facts = gptDiagnosticsFacts; - if (facts) { + const bus = diagnosticsBus; + if (facts && bus) { const releaseCapture = activateGptDiagnosticsFactCapture( composition.adapters.googletag, - facts + Object.freeze({ + publish: (fact: Readonly) => + bus.publish(fact as unknown as DiagnosticsObservation), + }) ); if (!releaseCapture) throw new Error('GPT diagnostics capture is unavailable'); context.onDispose(releaseCapture); diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 176c3136c..09ce72b1f 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -49,6 +49,7 @@ import { createSourcepointIntegrationRegistration } from '../../src/integrations import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; import { publicLog } from '../../src/kernel/fallback'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import type { IntegrationPrepareContext } from '../../src/kernel/integration_registry'; import { createRenderAttempt, type CommittedRenderArtifact, @@ -946,6 +947,93 @@ describe('browser composition', () => { expect(gpt.listenerInventory()).toEqual([]); }); + it('publishes committed GPT facts through the kernel diagnostics bus', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const observations: Readonly>[] = []; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [ + { id: 'diagnostics_probe', required: true }, + { id: 'gpt_diagnostics', required: true }, + ], + }, + knownIntegrationIds: Object.freeze(['diagnostics_probe', 'gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration({ + id: 'diagnostics_probe', + release: releaseId, + prepare: ({ interfaces, onDispose }: IntegrationPrepareContext) => { + const diagnostics = interfaces['diagnostics'] as { + subscribe( + id: string, + listener: (observation: Readonly>) => void + ): (() => void) | undefined; + }; + const release = diagnostics.subscribe('diagnostics_probe', (observation) => + observations.push(observation) + ); + if (!release) throw new Error('Expected the diagnostics bus subscription'); + onDispose(release); + return { activate: vi.fn() }; + }, + }) + ).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + + const observedSlot = Object.freeze({ + getSlotElementId: () => 'bus-slot', + getAdUnitPath: () => '/example/bus-slot', + }); + gpt.emit('slotRenderEnded', { slot: observedSlot, isEmpty: false }); + + await vi.waitFor(() => + expect(observations).toContainEqual( + expect.objectContaining({ + kind: 'slotRenderEnded', + slot: observedSlot, + isEmpty: false, + }) + ) + ); + } finally { + composition.runtime.dispose(); + } + }); + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; From 55341cf26ca6bfa8938524d778b1818841f66f66 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:22:43 -0700 Subject: [PATCH 388/494] Document TSJS manifest errors --- crates/trusted-server-core/src/tsjs.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 759f7216c..1d71a981d 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -10,6 +10,11 @@ use crate::error::TrustedServerError; /// `module_ids` contains enabled integration bundles in actual injection order; /// core is implicit and therefore rejected here. Unknown, duplicate, malformed, /// or over-capacity inventories fail closed. +/// +/// # Errors +/// +/// Returns an error when the integration inventory exceeds the bounded capacity, +/// contains an invalid module ID, or cannot be serialized. pub fn tsjs_boot_manifest_v1(module_ids: &[&str]) -> Result> { if module_ids.len() > 16 { return Err(boot_manifest_error("more than 16 integration modules")); From 624f27082041c9d44a8793a4a07835928eeb2e25 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:25:29 -0700 Subject: [PATCH 389/494] Format resilient TSJS modules --- .../trusted-server-js/lib/src/composition/browser.ts | 4 +--- .../lib/src/integrations/didomi/module.ts | 12 ++++++------ .../lib/src/integrations/osano/consent_mirror.ts | 5 +---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index daa503496..2698c870c 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -443,9 +443,7 @@ export function createTestBrowserRuntimeComposition( observation['kind'] === 'slotVisibilityChanged' ) { try { - gptDiagnosticsFacts?.publish( - observation as unknown as Readonly - ); + gptDiagnosticsFacts?.publish(observation as unknown as Readonly); } catch { // GPT diagnostics never affect an already-committed adapter observation. } diff --git a/crates/trusted-server-js/lib/src/integrations/didomi/module.ts b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts index 871bd9c51..4f8522310 100644 --- a/crates/trusted-server-js/lib/src/integrations/didomi/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/didomi/module.ts @@ -37,12 +37,12 @@ function didomiBootConfig(candidate: unknown): candidate is Readonly<{ proxyPath const descriptor = Object.getOwnPropertyDescriptor(candidate, 'proxyPath'); return Boolean( descriptor?.enumerable && - 'value' in descriptor && - typeof descriptor.value === 'string' && - descriptor.value.startsWith('/') && - !descriptor.value.startsWith('//') && - !descriptor.value.startsWith('/\\') && - descriptor.value.length <= 2_048 && + 'value' in descriptor && + typeof descriptor.value === 'string' && + descriptor.value.startsWith('/') && + !descriptor.value.startsWith('//') && + !descriptor.value.startsWith('/\\') && + descriptor.value.length <= 2_048 && !descriptor.value.includes('?') && !descriptor.value.includes('#') ); diff --git a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts index cb5b40516..88c6a983d 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/consent_mirror.ts @@ -437,10 +437,7 @@ function installOsanoListeners(): boolean { return true; } - if ( - typeof cm.addEventListener !== 'function' || - typeof cm.removeEventListener !== 'function' - ) { + if (typeof cm.addEventListener !== 'function' || typeof cm.removeEventListener !== 'function') { return false; } From 45de84d3c3d2c4d602832e876f03a673d822dbe7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:26:12 -0700 Subject: [PATCH 390/494] Fix duplicate Prebid artifact equality --- .../lib/build-prebid-external.mjs | 3 +- .../test/prebid-artifact-integration.test.mjs | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index 9d0b14aa4..75b5ee5fa 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -303,11 +303,12 @@ function renderExternalWrapper(bundleCode, stamp) { 'function __tsData(value,key){try{var descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&Object.prototype.hasOwnProperty.call(descriptor,"value")&&descriptor.enumerable===true&&descriptor.writable===false&&descriptor.configurable===false?descriptor.value:__tsMissing;}catch(_){return __tsMissing;}}', 'function __tsRecord(value,keys){if(!value||typeof value!=="object"||Object.getPrototypeOf(value)!==Object.prototype||!Object.isFrozen(value))return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==keys.length)return false;for(var i=0;imax)return false;var own;try{own=Reflect.ownKeys(value);}catch(_){return false;}if(own.length!==value.length+1)return false;for(var i=0;i=55296&&code<=56319){var next=value.charCodeAt(i+1);if(next<56320||next>57343)return false;bytes+=4;i+=1;}else if(code>=56320&&code<=57343)return false;else if(code<=127)bytes+=1;else if(code<=2047)bytes+=2;else bytes+=3;if(bytes>max)return false;}return true;}', 'function __tsSortedStrings(value,max,maxBytes,lowercase){if(!__tsArray(value,max))return false;var previous;for(var i=0;i=current))return false;previous=current;}return true;}', 'function __tsContains(values,expected){for(var i=0;i=identity)||!__tsContains(bidders,code)||!__tsContains(modules,stem))return false;previous=identity;}previous="";for(var j=0;j=name)||!__tsContains(modules,name)||!__tsSortedStrings(configs,64,128,false)||!__tsSortedStrings(sources,64,256,true))return false;previous=name;}return true;}catch(_){return false;}}', - 'function __tsEqual(left,right){if(left===right)return true;if(!left||!right||typeof left!=="object"||typeof right!=="object")return false;var leftKeys=Reflect.ownKeys(left);var rightKeys=Reflect.ownKeys(right);if(leftKeys.length!==rightKeys.length)return false;for(var i=0;i { dom.window.close(); }); + it('reuses separately constructed identical artifacts without reporting a conflict', () => { + const dom = new JSDOM('', { + url: 'https://pub.example.com/article', + runScripts: 'outside-only', + }); + const pageWindow = dom.window; + const watchdogs = []; + const originalSetTimeout = pageWindow.setTimeout.bind(pageWindow); + pageWindow.setTimeout = (callback, delay, ...arguments_) => { + if (delay === 5_000 && String(callback).includes('__tsWatchdogFired')) { + watchdogs.push(callback); + return 1; + } + return originalSetTimeout(callback, delay, ...arguments_); + }; + pageWindow.fetch = vi.fn(async () => new Response('{}')); + pageWindow.Request = Request; + pageWindow.Headers = Headers; + pageWindow.Response = Response; + pageWindow.AbortController = AbortController; + if (!('isSecureContext' in pageWindow)) pageWindow.isSecureContext = true; + pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); + const warn = vi.fn(); + pageWindow.console.warn = warn; + const firstBytes = Buffer.from(bundleCode, 'utf8'); + const duplicateBytes = Buffer.from(bundleCode, 'utf8'); + expect(firstBytes).not.toBe(duplicateBytes); + expect(firstBytes.equals(duplicateBytes)).toBe(true); + + pageWindow.eval(firstBytes.toString('utf8')); + const firstBinding = pageWindow.pbjs; + const firstRequestBids = firstBinding.requestBids; + const firstRegisterBidAdapter = firstBinding.registerBidAdapter; + const firstStamp = firstBinding.__trustedServerArtifactV1; + pageWindow.eval(duplicateBytes.toString('utf8')); + + expect(pageWindow.pbjs).toBe(firstBinding); + expect(pageWindow.pbjs.requestBids).toBe(firstRequestBids); + expect(pageWindow.pbjs.registerBidAdapter).toBe(firstRegisterBidAdapter); + expect(pageWindow.pbjs.__trustedServerArtifactV1).toBe(firstStamp); + expect(warn).not.toHaveBeenCalled(); + expect(watchdogs).toHaveLength(2); + + const processQueue = vi.fn(firstBinding.processQueue.bind(firstBinding)); + firstBinding.processQueue = processQueue; + for (const watchdog of watchdogs) { + watchdog(); + watchdog(); + } + expect(processQueue).toHaveBeenCalledTimes(2); + dom.window.close(); + }); + it('refuses a different valid artifact without disturbing the working binding', () => { const dom = new JSDOM('', { url: 'https://pub.example.com/article', From 5c124c5bf221c55a0f9dc63b66aca77e222832be Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:26:47 -0700 Subject: [PATCH 391/494] Harden creative boot and click ownership --- .../lib/src/integrations/creative/click.ts | 44 +++++-- .../lib/src/integrations/creative/module.ts | 3 +- .../lib/src/kernel/fallback.ts | 16 ++- .../lib/test/composition/browser.test.ts | 120 ++++++++++++++++++ .../test/integrations/creative/click.test.ts | 92 ++++++++++++++ .../test/integrations/creative/module.test.ts | 19 ++- .../lib/test/kernel/fallback.test.ts | 75 +++++++++++ 7 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/kernel/fallback.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index a1e496705..15fa41e15 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -165,7 +165,12 @@ function buildProxyRebuildUrl(tsClickStr: string, diff: Diff): string { // does not answer, and always fails — so the guard skips it and recovers via // the GET navigation fallback, which the edge answers with a 302 chain (no // CORS applies to navigations). -async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Promise { +async function rebuildClick( + a: AnchorLike, + tsClickStr: string, + diff: Diff, + isActive: () => boolean +): Promise { const addKeys = Object.keys(diff.add); const delKeys = diff.del; if (addKeys.length === 0 && delKeys.length === 0) { @@ -175,6 +180,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom const fallback = buildProxyRebuildUrl(tsClickStr, diff); if (typeof fetch !== 'function' || hasOpaqueOrigin()) { + if (!isActive()) return tsClickStr; try { const el = a as Element; el.setAttribute('href', fallback); @@ -195,6 +201,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom body: JSON.stringify(payload), credentials: 'same-origin', }); + if (!isActive()) return tsClickStr; if (!resp.ok) { log.warn('tsjs-creative:click: proxy-rebuild HTTP error', resp.status); try { @@ -206,6 +213,7 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom return fallback; } const data = (await resp.json()) as { href?: string; base?: string } | null; + if (!isActive()) return tsClickStr; const href = data && typeof data.href === 'string' ? data.href : null; if (href) { persistRebuiltClick(a, href); @@ -216,9 +224,11 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom return href; } } catch (err) { + if (!isActive()) return tsClickStr; log.warn('tsjs-creative:click: proxy-rebuild request failed', err); } + if (!isActive()) return tsClickStr; try { const el = a as Element; el.setAttribute('href', fallback); @@ -229,7 +239,11 @@ async function rebuildClick(a: AnchorLike, tsClickStr: string, diff: Diff): Prom } // Work out the href we should navigate to after accounting for creative rewrites. -async function computeFinalUrl(a: AnchorLike, tsClickStr: string): Promise { +async function computeFinalUrl( + a: AnchorLike, + tsClickStr: string, + isActive: () => boolean +): Promise { const orig = canonFromFirstPartyClick(tsClickStr); if (!orig) return tsClickStr; @@ -266,7 +280,7 @@ async function computeFinalUrl(a: AnchorLike, tsClickStr: string): Promise { - let finalUrl = await computeFinalUrl(anchor, tsClickStr); +async function rebuildIfNeeded( + anchor: AnchorLike, + tsClickStr: string, + isActive: () => boolean +): Promise { + let finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); + if (!isActive()) return tsClickStr; if (finalUrl === tsClickStr) { await delay(); - finalUrl = await computeFinalUrl(anchor, tsClickStr); + if (!isActive()) return tsClickStr; + finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); } return finalUrl; } @@ -352,7 +376,7 @@ async function guardNavigation( isMiddle: boolean, isActive: () => boolean ): Promise { - const finalUrl = await rebuildIfNeeded(anchor, tsClickStr); + const finalUrl = await rebuildIfNeeded(anchor, tsClickStr, isActive); if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { persistRebuiltClick(anchor, finalUrl); @@ -392,7 +416,7 @@ function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { if (!isActive()) return; const tsClickStr = anchor.getAttribute('data-tsclick') || ''; if (!tsClickStr) return; - void rebuildIfNeeded(anchor, tsClickStr) + void rebuildIfNeeded(anchor, tsClickStr, isActive) .then((finalUrl) => { if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { diff --git a/crates/trusted-server-js/lib/src/integrations/creative/module.ts b/crates/trusted-server-js/lib/src/integrations/creative/module.ts index f797267b9..c9d21d3b6 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/module.ts @@ -40,7 +40,8 @@ function readCreativeBoot(candidate: unknown): Readonly | undefi return values['version'] === 1 && typeof values['enabled'] === 'boolean' && typeof values['clickGuard'] === 'boolean' && - typeof values['renderGuard'] === 'boolean' + typeof values['renderGuard'] === 'boolean' && + (values['enabled'] || (!values['clickGuard'] && !values['renderGuard'])) ? (candidate as Readonly) : undefined; } catch { diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index 00ecf0206..84b777d5e 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -47,6 +47,16 @@ function ownDataRecord(value: unknown): Record | undefined { } } +function ownPlainDataRecord(value: unknown): Record | undefined { + const record = ownDataRecord(value); + if (!record) return undefined; + try { + return Object.getPrototypeOf(value) === Object.prototype ? record : undefined; + } catch { + return undefined; + } +} + function exactKeys(record: Record, keys: readonly string[]): boolean { const actual = Object.keys(record); return actual.length === keys.length && actual.every((key) => keys.includes(key)); @@ -138,7 +148,7 @@ export function buildKernelBoot( record.cachePolicy === undefined ? undefined : parseCachePolicy(record.cachePolicy); if (record.cachePolicy !== undefined && !cachePolicy) return undefined; const auctionProjection = parseBrowserAuctionProjectionV1(record.auctionProjection, cachePolicy); - const creative = ownDataRecord(record.creative); + const creative = ownPlainDataRecord(record.creative); const diagnostics = ownDataRecord(record.diagnostics); const gptDiagnostics = ownDataRecord(diagnostics?.gpt); if ( @@ -149,6 +159,7 @@ export function buildKernelBoot( typeof creative.enabled !== 'boolean' || typeof creative.clickGuard !== 'boolean' || typeof creative.renderGuard !== 'boolean' || + (!creative.enabled && (creative.clickGuard || creative.renderGuard)) || !diagnostics || !exactKeys(diagnostics, ['version', 'renderTraceOverlay', 'gpt']) || diagnostics.version !== 1 || @@ -160,7 +171,10 @@ export function buildKernelBoot( return undefined; } const diagnosticsModule = manifest.integrations.filter(({ id }) => id === 'gpt_diagnostics'); + const creativeModule = manifest.integrations.filter(({ id }) => id === 'creative'); if ( + (creative.enabled && creativeModule.length !== 1) || + (!creative.enabled && creativeModule.length !== 0) || (gptDiagnostics.active && diagnosticsModule.length !== 1) || (!gptDiagnostics.active && diagnosticsModule.length !== 0) ) { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 09ce72b1f..3352eae5b 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1388,6 +1388,126 @@ describe('browser composition', () => { expect(release).toHaveBeenCalledTimes(1); }); + it('commits enabled creative with both guards false without creative effects', async () => { + const releaseId = 'a'.repeat(64); + const activateCreative = vi.fn(); + const startCreative = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'creative', required: true }], + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + creativeActivationForTest: activateCreative, + creativeStartupForTest: startCreative, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createCreativeIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + expect(activateCreative).not.toHaveBeenCalled(); + expect(startCreative).not.toHaveBeenCalled(); + + composition.runtime.dispose(); + }); + + it.each([ + [ + 'disabled click guard bit', + { version: 1, enabled: false, clickGuard: true, renderGuard: false }, + [], + ], + [ + 'disabled render guard bit', + { version: 1, enabled: false, clickGuard: false, renderGuard: true }, + [], + ], + [ + 'disabled creative manifest member', + { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + ['creative'], + ], + [ + 'missing enabled creative manifest member', + { version: 1, enabled: true, clickGuard: false, renderGuard: false }, + [], + ], + ] as const)('rejects creative ABI mismatch: %s', async (_caseName, creative, manifestIds) => { + const releaseId = 'a'.repeat(64); + const activateCreative = vi.fn(); + const startCreative = vi.fn(); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: manifestIds.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: Object.freeze(['creative']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + creativeActivationForTest: activateCreative, + creativeStartupForTest: startCreative, + } + ); + + expect(composition.runtime.start()).toBe(true); + if (manifestIds.length === 1) { + expect( + composition.runtime.registerIntegration(createCreativeIntegrationRegistration(releaseId)) + ).toBe(true); + } + await expect(composition.runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'abi_mismatch', + }); + expect(activateCreative).not.toHaveBeenCalled(); + expect(startCreative).not.toHaveBeenCalled(); + }); + it('owns the real creative click guard through the composition lifecycle', async () => { const releaseId = 'a'.repeat(64); const addEventListener = vi.spyOn(document, 'addEventListener'); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index beb9411b1..ffa55987f 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -277,4 +277,96 @@ describe('creative/click.ts', () => { // unhandled navigation error is the assertion that location.href was // never assigned the javascript: URL. }); + + it.each([ + 'https://user@example.com/landing', + 'https://:password@example.com/landing', + 'https://%75ser:%70assword@example.com/landing', + ])('refuses a credential-bearing navigation URL: %s', async (targetUrl) => { + vi.useFakeTimers(); + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', targetUrl); + anchor.setAttribute('href', targetUrl); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + try { + await importCreativeModule(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).not.toHaveBeenCalled(); + expect(anchor.getAttribute('href')).toBe(targetUrl); + } finally { + window.open = originalOpen; + } + }); + + it.each([ + ['absolute', 'https://example.com/landing?campaign=fictional'], + ['root-relative', '/first-party/landing?campaign=fictional'], + ])('preserves valid %s HTTP(S) navigation', async (_caseName, targetUrl) => { + vi.useFakeTimers(); + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', targetUrl); + anchor.setAttribute('href', targetUrl); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + try { + await importCreativeModule(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).toHaveBeenCalledWith(absolute(targetUrl), '_blank', 'noopener,noreferrer'); + } finally { + window.open = originalOpen; + } + }); + + it.each(['success', 'error'] as const)( + 'does not persist a late proxy-rebuild %s after disposal', + async (outcome) => { + let resolveFetch: ((response: Response) => void) | undefined; + let rejectFetch: ((reason: unknown) => void) | undefined; + global.fetch = vi.fn( + () => + new Promise((resolve, reject) => { + resolveFetch = resolve; + rejectFetch = reject; + }) + ); + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + document.body.appendChild(anchor); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + const handle = installClickGuard(false); + + handle.scan(); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)); + handle.dispose(); + if (outcome === 'success') { + resolveFetch?.({ + ok: true, + json: async () => ({ href: PROXY_RESPONSE }), + } as Response); + } else { + rejectFetch?.(new Error('fictional late proxy failure')); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(anchor.getAttribute('href')).toBe(MUTATED_CLICK); + expect(anchor.getAttribute('data-tsclick')).toBe(FIRST_PARTY_CLICK); + } + ); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts index 22393ce85..7b0eee040 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/module.test.ts @@ -97,10 +97,13 @@ describe('transactional creative integration module', () => { expect(release).toHaveBeenCalledTimes(1); }); - it.each([ - Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: true }), - Object.freeze({ version: 1, enabled: true, clickGuard: false, renderGuard: false }), - ])('performs no runtime work for an inactive creative boot %#', async (config) => { + it('performs no runtime work when enabled with both guards false', async () => { + const config = Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: false, + renderGuard: false, + }); const activate = vi.fn(); const start = vi.fn(); const registry = createIntegrationRegistry({ @@ -192,6 +195,14 @@ describe('transactional creative integration module', () => { ), ], ['mutable object', { version: 1, enabled: true, clickGuard: true, renderGuard: false }], + [ + 'disabled click guard', + Object.freeze({ version: 1, enabled: false, clickGuard: true, renderGuard: false }), + ], + [ + 'disabled render guard', + Object.freeze({ version: 1, enabled: false, clickGuard: false, renderGuard: true }), + ], ])('rejects %s configuration during inert preparation', async (_caseName, config) => { const activate = vi.fn(); const start = vi.fn(); diff --git a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts new file mode 100644 index 000000000..be6c07539 --- /dev/null +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { buildKernelBoot } from '../../src/kernel/fallback'; + +const RELEASE_ID = 'a'.repeat(64); + +function manifest(ids: readonly string[]) { + return { + version: 1 as const, + releaseId: RELEASE_ID, + integrations: ids.map((id) => ({ id, required: true as const })), + }; +} + +function boot(creative: unknown) { + return { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; +} + +describe('kernel boot creative ABI', () => { + it.each([ + { version: 1, enabled: false, clickGuard: true, renderGuard: false }, + { version: 1, enabled: false, clickGuard: false, renderGuard: true }, + ])('rejects disabled creative with an enabled guard bit', (creative) => { + expect(buildKernelBoot(RELEASE_ID, manifest([]), boot(creative))).toBeUndefined(); + }); + + it('rejects a null-prototype creative record', () => { + const creative = Object.assign(Object.create(null) as object, { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }); + + expect(buildKernelBoot(RELEASE_ID, manifest([]), boot(creative))).toBeUndefined(); + }); + + it.each([ + ['enabled creative without a manifest member', true, []], + ['enabled creative with duplicate manifest members', true, ['creative', 'creative']], + ['disabled creative with a manifest member', false, ['creative']], + ] as const)('rejects %s', (_caseName, enabled, ids) => { + expect( + buildKernelBoot( + RELEASE_ID, + manifest(ids), + boot({ version: 1, enabled, clickGuard: false, renderGuard: false }) + ) + ).toBeUndefined(); + }); + + it('accepts enabled creative with both guards false only with one manifest member', () => { + const accepted = buildKernelBoot( + RELEASE_ID, + manifest(['creative']), + boot({ version: 1, enabled: true, clickGuard: false, renderGuard: false }) + ) as { readonly creative?: unknown } | undefined; + + expect(accepted?.creative).toEqual({ + version: 1, + enabled: true, + clickGuard: false, + renderGuard: false, + }); + expect(Object.isFrozen(accepted?.creative)).toBe(true); + }); +}); From eec0b9982ebaeed5897f7b348fd732acc291259c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:39:39 -0700 Subject: [PATCH 392/494] Isolate creative lifecycle ownership --- .../lib/src/integrations/creative/click.ts | 59 +++++++++++-------- .../lib/src/integrations/creative/startup.ts | 10 +++- .../test/integrations/creative/click.test.ts | 51 ++++++++++++++++ .../integrations/creative/startup.test.ts | 36 +++++++++++ 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts index 15fa41e15..2ddb4cef5 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -10,15 +10,7 @@ import type { CreativeGuardHandle } from './startup'; type AnchorLike = HTMLAnchorElement | HTMLAreaElement; type Canon = { base: string; params: Record }; type Diff = { add: Record; del: string[] }; - -// Rebuild URLs already written to an anchor's href by an earlier repair pass -// (the opaque-origin GET fallback). They are not `/first-party/click` URLs, so -// they cannot be canonicalized and deliberately never replace the canonical -// `data-tsclick`. Without remembering them, a later click would canonicalize -// the fallback against the original signed click, fail the base comparison, and -// navigate the pre-mutation URL — silently dropping the mutation the fallback -// exists to carry. -const pendingRebuilds = new WeakMap(); +type PendingRebuilds = WeakMap; // Allow query/localStorage flag to crank logging when debugging creatives. function enableDebugFromEnv(): void { @@ -169,6 +161,7 @@ async function rebuildClick( a: AnchorLike, tsClickStr: string, diff: Diff, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { const addKeys = Object.keys(diff.add); @@ -216,7 +209,7 @@ async function rebuildClick( if (!isActive()) return tsClickStr; const href = data && typeof data.href === 'string' ? data.href : null; if (href) { - persistRebuiltClick(a, href); + persistRebuiltClick(a, href, pendingRebuilds); log.info('tsjs-creative:click: rebuilt click', { added: addKeys, removed: delKeys, @@ -242,6 +235,7 @@ async function rebuildClick( async function computeFinalUrl( a: AnchorLike, tsClickStr: string, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { const orig = canonFromFirstPartyClick(tsClickStr); @@ -280,7 +274,7 @@ async function computeFinalUrl( del: diff.del, }); - return rebuildClick(a, tsClickStr, diff, isActive); + return rebuildClick(a, tsClickStr, diff, pendingRebuilds, isActive); } // Resolve a click URL against the pinned trusted base and require an http(s) @@ -325,7 +319,11 @@ function navigate(a: AnchorLike, url: string, isMiddle: boolean): void { // compare against — is only updated when the value is itself a signed // /first-party/click URL. Writing the GET proxy-rebuild fallback there would // make every later canonicalization fail and lose subsequent mutations. -function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { +function persistRebuiltClick( + anchor: AnchorLike, + finalUrl: string, + pendingRebuilds: PendingRebuilds +): void { // Persist the validated, absolutized URL — never the raw input. Beyond // enforcing the http(s) allowlist, an absolute URL keeps the anchor's // default navigation working inside the srcdoc iframe, where a relative @@ -357,14 +355,15 @@ function persistRebuiltClick(anchor: AnchorLike, finalUrl: string): void { async function rebuildIfNeeded( anchor: AnchorLike, tsClickStr: string, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { - let finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); + let finalUrl = await computeFinalUrl(anchor, tsClickStr, pendingRebuilds, isActive); if (!isActive()) return tsClickStr; if (finalUrl === tsClickStr) { await delay(); if (!isActive()) return tsClickStr; - finalUrl = await computeFinalUrl(anchor, tsClickStr, isActive); + finalUrl = await computeFinalUrl(anchor, tsClickStr, pendingRebuilds, isActive); } return finalUrl; } @@ -374,18 +373,24 @@ async function guardNavigation( anchor: AnchorLike, tsClickStr: string, isMiddle: boolean, + pendingRebuilds: PendingRebuilds, isActive: () => boolean ): Promise { - const finalUrl = await rebuildIfNeeded(anchor, tsClickStr, isActive); + const finalUrl = await rebuildIfNeeded(anchor, tsClickStr, pendingRebuilds, isActive); if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { - persistRebuiltClick(anchor, finalUrl); + persistRebuiltClick(anchor, finalUrl, pendingRebuilds); } navigate(anchor, finalUrl || tsClickStr, isMiddle); } // Entry point for click/auxclick handlers: prevent default and queue guarded nav. -function handleGuardedClick(ev: Event, isMiddle: boolean, isActive: () => boolean): void { +function handleGuardedClick( + ev: Event, + isMiddle: boolean, + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): void { const anchor = closestAnchor(ev.target); if (!anchor) return; @@ -396,7 +401,7 @@ function handleGuardedClick(ev: Event, isMiddle: boolean, isActive: () => boolea const runNavigation = () => { if (!isActive()) return; - void guardNavigation(anchor, tsClickStr, isMiddle, isActive).catch((err) => { + void guardNavigation(anchor, tsClickStr, isMiddle, pendingRebuilds, isActive).catch((err) => { if (!isActive()) return; log.warn('tsjs-creative:click: failed to compute final URL', err); navigate(anchor, tsClickStr, isMiddle); @@ -407,7 +412,10 @@ function handleGuardedClick(ev: Event, isMiddle: boolean, isActive: () => boolea } // Observe href/data-tsclick mutations and repair anchors that third parties touch. -function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { +function monitorAnchorMutations( + pendingRebuilds: PendingRebuilds, + isActive: () => boolean +): CreativeGuardHandle { if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { return Object.freeze({ dispose: () => undefined, scan: () => undefined }); } @@ -416,11 +424,11 @@ function monitorAnchorMutations(isActive: () => boolean): CreativeGuardHandle { if (!isActive()) return; const tsClickStr = anchor.getAttribute('data-tsclick') || ''; if (!tsClickStr) return; - void rebuildIfNeeded(anchor, tsClickStr, isActive) + void rebuildIfNeeded(anchor, tsClickStr, pendingRebuilds, isActive) .then((finalUrl) => { if (!isActive()) return; if (finalUrl && finalUrl !== tsClickStr) { - persistRebuiltClick(anchor, finalUrl); + persistRebuiltClick(anchor, finalUrl, pendingRebuilds); } }) .catch((err) => { @@ -471,17 +479,20 @@ export function installClickGuard(scanInitially = true): CreativeGuardHandle { enableDebugFromEnv(); log.info('tsjs-creative:click: installing click guard'); + // Opaque rebuild recognition belongs to this exact guard generation. A new + // installation must never inherit a disposed generation's anchor state. + const pendingRebuilds: PendingRebuilds = new WeakMap(); let active = true; const isActive = (): boolean => active; const onClick = (ev: Event) => { if (!active) return; - handleGuardedClick(ev, false, isActive); + handleGuardedClick(ev, false, pendingRebuilds, isActive); }; const onAuxClick = (ev: MouseEvent) => { if (!active) return; if (ev.button !== 1) return; - handleGuardedClick(ev, true, isActive); + handleGuardedClick(ev, true, pendingRebuilds, isActive); }; document.addEventListener('click', onClick, true); @@ -496,7 +507,7 @@ export function installClickGuard(scanInitially = true): CreativeGuardHandle { mutations?.dispose(); }; try { - mutations = monitorAnchorMutations(isActive); + mutations = monitorAnchorMutations(pendingRebuilds, isActive); const handle = Object.freeze({ dispose, scan: (): void => mutations?.scan(), diff --git a/crates/trusted-server-js/lib/src/integrations/creative/startup.ts b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts index cf5167612..916a3fbe2 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/startup.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/startup.ts @@ -92,7 +92,15 @@ export function createCreativeStartup(options: CreativeStartupOptions): Creative options.document.addEventListener('DOMContentLoaded', readyListener, { once: true }); } } catch (error) { - disposeHandles(); + const listener = readyListener; + readyListener = undefined; + try { + if (listener) options.document.removeEventListener('DOMContentLoaded', listener); + } catch { + // Preserve the activation failure while completing owned guard rollback. + } finally { + disposeHandles(); + } throw error; } return (): void => { diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index ffa55987f..517e30e55 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -255,6 +255,57 @@ describe('creative/click.ts', () => { } }); + it('does not reuse an opaque rebuild from a disposed guard generation', async () => { + vi.useFakeTimers(); + const nextClick = + '/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Fnext&wave=2&tstoken=nexttoken'; + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + global.fetch = undefined as unknown as typeof fetch; + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + let firstGeneration: { dispose(): void; scan(): void } | undefined; + let secondGeneration: { dispose(): void; scan(): void } | undefined; + + try { + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', FIRST_PARTY_CLICK); + anchor.setAttribute('href', MUTATED_CLICK); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + const { installClickGuard } = await import('../../../src/integrations/creative/click'); + + firstGeneration = installClickGuard(false); + firstGeneration.scan(); + await Promise.resolve(); + await vi.runAllTimersAsync(); + const firstFallback = anchor.getAttribute('href') ?? ''; + expect(firstFallback.startsWith(REBUILD_PREFIX)).toBe(true); + + firstGeneration.dispose(); + anchor.setAttribute('data-tsclick', nextClick); + expect(anchor.getAttribute('href')).toBe(firstFallback); + + secondGeneration = installClickGuard(false); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(openMock).toHaveBeenCalledWith(absolute(nextClick), '_blank', 'noopener,noreferrer'); + expect(openMock).not.toHaveBeenCalledWith(firstFallback, '_blank', 'noopener,noreferrer'); + } finally { + secondGeneration?.dispose(); + firstGeneration?.dispose(); + window.open = originalOpen; + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); + it('refuses to navigate to or persist non-http(s) URLs', async () => { // The guard reads creative-controlled attributes; a javascript: value must // never reach location.href or an href write. diff --git a/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts index 8a7bf7a30..1989af611 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/startup.test.ts @@ -126,6 +126,42 @@ describe('creative startup ownership', () => { expect(order).toEqual(['dispose:image', 'dispose:click']); }); + it('removes an exact ready listener when hostile registration throws after installing it', () => { + const order: string[] = []; + const click = guard('click', order); + let listener: (() => void) | undefined; + const document = { + readyState: 'loading' as const, + addEventListener: vi.fn( + (_type: 'DOMContentLoaded', candidate: () => void, _options: { once: true }) => { + listener = candidate; + throw new Error('fictional ready listener registration failure'); + } + ), + removeEventListener: vi.fn((_type: 'DOMContentLoaded', candidate: () => void) => { + if (listener === candidate) listener = undefined; + }), + }; + const startup = createCreativeStartup({ + document, + installClickGuard: () => click, + installDynamicImageProxy: () => guard('image', order), + installDynamicIframeProxy: () => guard('iframe', order), + }); + + expect(() => startup.activate(config({ renderGuard: false }))).toThrow( + 'fictional ready listener registration failure' + ); + expect(document.removeEventListener).toHaveBeenCalledExactlyOnceWith( + 'DOMContentLoaded', + expect.any(Function) + ); + expect(click.dispose).toHaveBeenCalledTimes(1); + + listener?.(); + expect(click.scan).not.toHaveBeenCalled(); + }); + it('contains hostile scans and still visits every active guard', async () => { const order: string[] = []; const click = guard('click', order); From 5f7d37a153072d9216a422f316c6be660ff9adeb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:46:21 -0700 Subject: [PATCH 393/494] Restore Testlight queue push parity --- .../lib/src/integrations/testlight/module.ts | 8 ++++++-- .../integrations/testlight/module.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/module.ts b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts index dbbab6951..9dddf69d7 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/module.ts @@ -190,11 +190,15 @@ export function createTestlightRuntime( } const pending = ownQueueValues(queue); queue.length = 0; + const nativePush = queue.push.bind(queue); Object.defineProperty(queue, 'push', { configurable: true, enumerable: false, value: (...candidates: unknown[]): number => { - for (const candidate of candidates) { + const length = nativePush(...candidates); + const forwarded = ownQueueValues(queue); + queue.length = 0; + for (const candidate of forwarded) { if (typeof candidate !== 'function') continue; try { dependencies.enqueue(candidate as () => void); @@ -203,7 +207,7 @@ export function createTestlightRuntime( log.debug('testlight shim: queued callback threw', error); } } - return 0; + return length; }, writable: false, }); diff --git a/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts index a18e6e199..136f2b60f 100644 --- a/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/testlight/module.test.ts @@ -50,6 +50,25 @@ describe('transactional Testlight integration module', () => { expect(original).toEqual([expect.any(Function), later]); }); + it('returns the captured native push result after forwarding a later callback', () => { + const callback = vi.fn(); + const target = { testlight: { que: [] as unknown[] } }; + const runtime = createTestlightRuntime({ + enqueue: (candidate) => candidate(), + started: vi.fn(), + target, + }); + + const release = runtime.activate(undefined); + runtime.start(undefined); + + expect(target.testlight.que.push(callback)).toBe(1); + expect(callback).toHaveBeenCalledOnce(); + expect(target.testlight.que).toHaveLength(0); + + release(); + }); + it('does not overwrite a publisher queue replacement during disposal', () => { const target = { testlight: { que: [] as unknown[] } }; const runtime = createTestlightRuntime({ From 11aefe9c1171ad6cd27bf231405573327eb341a3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:47:30 -0700 Subject: [PATCH 394/494] Harden GPT diagnostics fact ownership --- .../lib/src/adapters/googletag.ts | 54 ++++++++++- .../trusted-server-js/lib/src/core/trace.ts | 11 ++- .../integrations/gpt_diagnostics/observer.ts | 93 ++++++++++++++----- .../src/integrations/gpt_diagnostics/store.ts | 61 ++++++++---- .../lib/src/kernel/diagnostics.ts | 7 +- .../lib/test/adapters/googletag.test.ts | 22 ++++- .../lib/test/core/trace_runtime.test.ts | 10 ++ .../gpt_diagnostics/facts.test.ts | 11 ++- .../gpt_diagnostics/index.test.ts | 16 ++-- .../gpt_diagnostics/observer.test.ts | 64 ++++++++----- .../gpt_diagnostics/store.test.ts | 16 ++++ .../lib/test/kernel/diagnostics.test.ts | 14 +++ 12 files changed, 292 insertions(+), 87 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 30779fd7b..fb49c48a3 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -211,7 +211,8 @@ export type GoogletagDiagnosticsEventName = export interface GoogletagDiagnosticsFact { readonly kind: GoogletagDiagnosticsEventName; - readonly slot: object; + readonly observedAtMs: number; + readonly slot: GoogletagDiagnosticsSlotSnapshot; readonly isEmpty?: boolean; readonly size?: readonly [number, number]; readonly isBackfill?: boolean; @@ -219,6 +220,13 @@ export interface GoogletagDiagnosticsFact { readonly inViewPercentage?: number; } +/** Frozen, non-authoritative identity and metadata captured from one physical GPT slot. */ +export interface GoogletagDiagnosticsSlotSnapshot { + readonly token: object; + readonly elementId?: string; + readonly adUnitPath?: string; +} + export type GoogletagDiagnosticsObserver = (fact: Readonly) => void; /** Browser surface owned by the concrete GPT adapter. */ @@ -861,6 +869,7 @@ export function createBrowserGoogletagAdapter( const targetingObservations = new WeakMap(); const facadeCalls = new WeakMap<(...arguments_: unknown[]) => unknown, number>(); const bindingTokens = new WeakMap(); + const diagnosticsSlots = new WeakMap(); const initialLoadReleases = new Map void>(); const initialLoadOwner = Object.freeze({}); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; @@ -870,7 +879,8 @@ export function createBrowserGoogletagAdapter( const diagnosticFact = ( eventType: string, - event: unknown + event: unknown, + observedAtMs: number ): Readonly | undefined => { try { if ((typeof event !== 'object' || event === null) && typeof event !== 'function') { @@ -880,7 +890,30 @@ export function createBrowserGoogletagAdapter( if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { return undefined; } - const base = { kind: eventType, slot: slot as object }; + const physicalSlot = slot as object; + let safeSlot = weakMapValue(diagnosticsSlots, physicalSlot); + if (!safeSlot) { + const optionalStringCall = (key: 'getSlotElementId' | 'getAdUnitPath'): string | undefined => { + const method = safeMember(physicalSlot, key); + if (typeof method !== 'function') return undefined; + try { + const value = Reflect.apply(method, physicalSlot, []); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } catch { + return undefined; + } + }; + const token = Object.freeze(Object.create(null) as object); + const elementId = optionalStringCall('getSlotElementId'); + const adUnitPath = optionalStringCall('getAdUnitPath'); + safeSlot = Object.freeze({ + token, + ...(elementId === undefined ? {} : { elementId }), + ...(adUnitPath === undefined ? {} : { adUnitPath }), + }); + setWeakMapValue(diagnosticsSlots, physicalSlot, safeSlot); + } + const base = { kind: eventType, observedAtMs, slot: safeSlot }; switch (eventType) { case 'slotRequested': case 'slotResponseReceived': @@ -931,7 +964,20 @@ export function createBrowserGoogletagAdapter( const publishDiagnostics = (eventType: string, event: unknown): void => { const observer = diagnosticsObserver; if (!observer || disposed) return; - const fact = diagnosticFact(eventType, event); + let observedAtMs = 0; + try { + const performance = safeMember(target, 'performance'); + if ((typeof performance === 'object' && performance !== null) || typeof performance === 'function') { + const now = safeMember(performance as object, 'now'); + if (typeof now === 'function') { + const value = Reflect.apply(now, performance, []); + if (typeof value === 'number' && Number.isFinite(value)) observedAtMs = value; + } + } + } catch { + // A missing or hostile clock cannot suppress the observed GPT fact. + } + const fact = diagnosticFact(eventType, event, observedAtMs); if (!fact) return; try { observer(fact); diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 20a99a20f..836c7408f 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -932,6 +932,7 @@ export function createRenderTraceDiagnostics( options: RenderTraceRuntimeOptions = {} ): RenderTraceRuntimeOwner { const current = new Map>(); + const counts = new Map(); const history: Array> = []; const recordsBySequence = new Map>(); const subscribers = new Map(); @@ -1037,9 +1038,16 @@ export function createRenderTraceDiagnostics( } catch { at = Date.now(); } + const previousCount = counts.get(input.slotId) ?? 0; + if (!counts.has(input.slotId) && counts.size >= MAX_RENDER_TRACE_SLOTS) { + const oldestCount = counts.keys().next().value as string | undefined; + if (oldestCount !== undefined) counts.delete(oldestCount); + } + counts.delete(input.slotId); + counts.set(input.slotId, previousCount + 1); const committed = copyRenderTraceRecord({ ...input, - count: (previous?.count ?? 0) + 1, + count: previousCount + 1, seq: (sequence += 1), at, }); @@ -1166,6 +1174,7 @@ export function createRenderTraceDiagnostics( current.clear(); history.length = 0; recordsBySequence.clear(); + counts.clear(); presentation.dispose(); }; diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts index 472ee30a6..0826b8bc7 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts @@ -6,12 +6,20 @@ import type { GptDiagnosticsSlotLike, GptRenderFacts } from './store'; export interface GptDiagnosticsObserverStore { markGptObserved(): void; - recordSlotRequested(slot: GptDiagnosticsSlotLike): void; - recordSlotResponseReceived(slot: GptDiagnosticsSlotLike): void; - recordSlotRenderEnded(slot: GptDiagnosticsSlotLike, facts: GptRenderFacts): void; - recordSlotOnload(slot: GptDiagnosticsSlotLike): void; - recordImpressionViewable(slot: GptDiagnosticsSlotLike): void; - recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void; + recordSlotRequested(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordSlotResponseReceived(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordSlotRenderEnded( + slot: GptDiagnosticsSlotLike, + facts: GptRenderFacts, + timestampMs?: number + ): void; + recordSlotOnload(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordImpressionViewable(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; + recordSlotVisibilityChanged( + slot: GptDiagnosticsSlotLike, + percentage: number, + timestampMs?: number + ): void; } interface ObserverLogger { @@ -27,6 +35,7 @@ export class GptDiagnosticsObserver { private readonly store: GptDiagnosticsObserverStore; private readonly logger: ObserverLogger; private started = false; + private observed = false; constructor(store: GptDiagnosticsObserverStore, options: ObserverOptions = {}) { this.store = store; @@ -36,47 +45,87 @@ export class GptDiagnosticsObserver { start(): void { if (this.started) return; this.started = true; - this.handle('activation', () => this.store.markGptObserved()); } consume(fact: Readonly): void { this.start(); + if (!this.observed) { + this.observed = true; + this.handle('observation', () => this.store.markGptObserved()); + } const slot = fact.slot as GptDiagnosticsSlotLike; + const observedAtMs = + typeof fact.observedAtMs === 'number' && Number.isFinite(fact.observedAtMs) + ? fact.observedAtMs + : undefined; switch (fact.kind) { case 'slotRequested': - this.handle(fact.kind, () => this.store.recordSlotRequested(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordSlotRequested(slot) + : this.store.recordSlotRequested(slot, observedAtMs) + ); return; case 'slotResponseReceived': - this.handle(fact.kind, () => this.store.recordSlotResponseReceived(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordSlotResponseReceived(slot) + : this.store.recordSlotResponseReceived(slot, observedAtMs) + ); return; case 'slotRenderEnded': this.handle(fact.kind, () => - this.store.recordSlotRenderEnded(slot, { - isEmpty: fact.isEmpty, - size: fact.size ? ([...fact.size] as Size) : undefined, - isBackfill: fact.isBackfill, - slotContentChanged: fact.slotContentChanged, - }) + observedAtMs === undefined + ? this.store.recordSlotRenderEnded(slot, { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + }) + : this.store.recordSlotRenderEnded( + slot, + { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + }, + observedAtMs + ) ); return; case 'slotOnload': - this.handle(fact.kind, () => this.store.recordSlotOnload(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordSlotOnload(slot) + : this.store.recordSlotOnload(slot, observedAtMs) + ); return; case 'impressionViewable': - this.handle(fact.kind, () => this.store.recordImpressionViewable(slot)); + this.handle(fact.kind, () => + observedAtMs === undefined + ? this.store.recordImpressionViewable(slot) + : this.store.recordImpressionViewable(slot, observedAtMs) + ); return; case 'slotVisibilityChanged': this.handle(fact.kind, () => - this.store.recordSlotVisibilityChanged( - slot, - typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN - ) + observedAtMs === undefined + ? this.store.recordSlotVisibilityChanged( + slot, + typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN + ) + : this.store.recordSlotVisibilityChanged( + slot, + typeof fact.inViewPercentage === 'number' ? fact.inViewPercentage : Number.NaN, + observedAtMs + ) ); } } private handle( - kind: GoogletagDiagnosticsFact['kind'] | 'activation', + kind: GoogletagDiagnosticsFact['kind'] | 'observation', callback: () => void ): void { try { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index c5efe25cd..c0cff3276 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -22,6 +22,9 @@ const CALLBACK_KINDS: GptDiagnosticsCallbackKind[] = [ ]; export interface GptDiagnosticsSlotLike { + readonly token?: object | undefined; + readonly elementId?: string | undefined; + readonly adUnitPath?: string | undefined; getSlotElementId?: (() => string) | undefined; getAdUnitPath?: (() => string) | undefined; } @@ -165,8 +168,8 @@ export class GptDiagnosticsStore { return () => this.listeners.delete(listener); } - recordSlotRequested(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordSlotRequested(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); const record = this.prepareCallback('slotRequested', slot, timestampMs); if (!record) return; @@ -175,8 +178,9 @@ export class GptDiagnosticsStore { this.metadata.evictedRequestCycles += 1; } - const requestNumber = (this.requestNumbers.get(slot) ?? 0) + 1; - this.requestNumbers.set(slot, requestNumber); + const identity = slot.token ?? slot; + const requestNumber = (this.requestNumbers.get(identity) ?? 0) + 1; + this.requestNumbers.set(identity, requestNumber); record.requests.push({ requestNumber, requestedAtMs: timestampMs, @@ -187,8 +191,8 @@ export class GptDiagnosticsStore { this.notify(); } - recordSlotResponseReceived(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordSlotResponseReceived(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'slotResponseReceived', slot, @@ -213,8 +217,12 @@ export class GptDiagnosticsStore { ); } - recordSlotRenderEnded(slot: GptDiagnosticsSlotLike, facts: GptRenderFacts): void { - const timestampMs = this.timestamp(); + recordSlotRenderEnded( + slot: GptDiagnosticsSlotLike, + facts: GptRenderFacts, + observedAtMs?: number + ): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'slotRenderEnded', slot, @@ -244,8 +252,8 @@ export class GptDiagnosticsStore { ); } - recordSlotOnload(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordSlotOnload(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'slotOnload', slot, @@ -262,8 +270,8 @@ export class GptDiagnosticsStore { ); } - recordImpressionViewable(slot: GptDiagnosticsSlotLike): void { - const timestampMs = this.timestamp(); + recordImpressionViewable(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { + const timestampMs = this.timestamp(observedAtMs); this.matchCycle( 'impressionViewable', slot, @@ -288,8 +296,12 @@ export class GptDiagnosticsStore { ); } - recordSlotVisibilityChanged(slot: GptDiagnosticsSlotLike, percentage: number): void { - const timestampMs = this.timestamp(); + recordSlotVisibilityChanged( + slot: GptDiagnosticsSlotLike, + percentage: number, + observedAtMs?: number + ): void { + const timestampMs = this.timestamp(observedAtMs); const record = this.prepareCallback('slotVisibilityChanged', slot, timestampMs); if (!record) return; @@ -354,9 +366,11 @@ export class GptDiagnosticsStore { }; } - private timestamp(): number { + private timestamp(observedAtMs?: number): number { this.gptObserved = true; - return this.now(); + return typeof observedAtMs === 'number' && Number.isFinite(observedAtMs) + ? observedAtMs + : this.now(); } private prepareCallback( @@ -365,7 +379,8 @@ export class GptDiagnosticsStore { timestampMs: number ): MutableSlotRecord | undefined { this.coverage[kind].observed += 1; - const existingNumber = this.slotNumbers.get(slot); + const identity = slot.token ?? slot; + const existingNumber = this.slotNumbers.get(identity); if (existingNumber !== undefined) { const existingRecord = this.slots.get(existingNumber); if (existingRecord) { @@ -404,7 +419,7 @@ export class GptDiagnosticsStore { runtimeSlotNumber, requests: [], }; - this.slotNumbers.set(slot, runtimeSlotNumber); + this.slotNumbers.set(identity, runtimeSlotNumber); this.refreshSlotMetadata(record, slot); this.slots.set(runtimeSlotNumber, record); this.slotOrder.push(runtimeSlotNumber); @@ -413,10 +428,16 @@ export class GptDiagnosticsStore { } private refreshSlotMetadata(record: MutableSlotRecord, slot: GptDiagnosticsSlotLike): void { - record.slotElementId ??= optionalNonEmptyString( + record.slotElementId ??= + (typeof slot.elementId === 'string' && slot.elementId.length > 0 + ? slot.elementId + : undefined) ?? optionalNonEmptyString( typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined ); - record.adUnitPath ??= optionalNonEmptyString( + record.adUnitPath ??= + (typeof slot.adUnitPath === 'string' && slot.adUnitPath.length > 0 + ? slot.adUnitPath + : undefined) ?? optionalNonEmptyString( typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined ); } diff --git a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts index 11fd03f9b..08ff3f12e 100644 --- a/crates/trusted-server-js/lib/src/kernel/diagnostics.ts +++ b/crates/trusted-server-js/lib/src/kernel/diagnostics.ts @@ -56,17 +56,16 @@ function recursivelyFrozenRecord(candidate: unknown): candidate is DiagnosticsOb const visited = new Set(); let nodes = 0; const visit = (value: unknown, depth: number): boolean => { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return true; + if (typeof value === 'function') return false; + if (typeof value !== 'object' || value === null) return true; if (visited.has(value)) return true; if (depth > MAX_OBSERVATION_DEPTH || nodes >= MAX_OBSERVATION_NODES) return false; visited.add(value); nodes += 1; try { - if (typeof value === 'function') return true; const prototype = Object.getPrototypeOf(value) as unknown; if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { - // GPT physical-slot objects are opaque identities, not diagnostic data. - return true; + return false; } if (!Object.isFrozen(value)) return false; const keys = Reflect.ownKeys(value); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index ed701f29c..1aadcdae0 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1105,7 +1105,8 @@ describe('browser googletag adapter readiness', () => { it('publishes frozen diagnostics facts after the sole adapter listener completes', async () => { const ready = createReadyGoogletag(); - const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const performance = { now: vi.fn(() => 42.25) }; + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag, performance }); const order: string[] = []; const facts: unknown[] = []; const releaseDiagnostics = adapter.observeDiagnostics?.((fact) => { @@ -1122,7 +1123,11 @@ describe('browser googletag adapter readiness', () => { }) ).result; expect(ready.pubads.addEventListener).toHaveBeenCalledTimes(1); - const slot = Object.freeze({ id: 'fictional-slot' }); + const slot = Object.freeze({ + getSlotElementId: () => 'fictional-slot', + getAdUnitPath: () => '/example/fictional-slot', + setTargeting: vi.fn(), + }); const emit = (event: unknown): void => { for (const listener of ready.listeners.get('slotRenderEnded') ?? []) listener(event); }; @@ -1140,7 +1145,12 @@ describe('browser googletag adapter readiness', () => { expect(facts).toEqual([ { kind: 'slotRenderEnded', - slot, + observedAtMs: 42.25, + slot: { + token: expect.any(Object), + elementId: 'fictional-slot', + adUnitPath: '/example/fictional-slot', + }, isEmpty: false, size: [300, 250], isBackfill: true, @@ -1149,6 +1159,12 @@ describe('browser googletag adapter readiness', () => { ]); expect(Object.isFrozen(facts[0])).toBe(true); expect(Object.isFrozen((facts[0] as { size: unknown }).size)).toBe(true); + const safeSlot = (facts[0] as { slot: Record }).slot; + expect(Object.isFrozen(safeSlot)).toBe(true); + expect(Object.isFrozen(safeSlot['token'])).toBe(true); + expect(Reflect.ownKeys(safeSlot).sort()).toEqual(['adUnitPath', 'elementId', 'token']); + expect(Object.values(safeSlot).some((value) => typeof value === 'function')).toBe(false); + expect(safeSlot).not.toBe(slot); releaseDiagnostics?.(); emit({ slot, isEmpty: true }); diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index 0f1f9da8c..ef63e0065 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -148,6 +148,16 @@ describe('render trace diagnostics runtime', () => { expect(history[0]?.seq).toBeGreaterThan(1); }); + it('retains a bounded document-lifetime slot count after current-state pruning', () => { + const { owner } = harness(); + const first = owner.record({ slotId: 'reused-slot', path: 'auction', rendered: true }); + + expect(owner.prune('reused-slot', first.seq)).toBe(true); + const second = owner.record({ slotId: 'reused-slot', path: 'gam-refresh', rendered: false }); + + expect(second.count).toBe(2); + }); + it('retains impression bookkeeping and refuses truth-weakening enrichment', () => { const { owner } = harness(); const record = owner.record({ diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts index dd21c36b8..ed19998b5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/facts.test.ts @@ -12,7 +12,14 @@ import { } from '../../../src/integrations/gpt_diagnostics/facts'; function fact(index: number): Readonly { - return Object.freeze({ kind: 'slotRequested', slot: Object.freeze({ index }) }); + return Object.freeze({ + kind: 'slotRequested', + observedAtMs: index, + slot: Object.freeze({ + token: Object.freeze(Object.create(null) as object), + elementId: `slot-${index}`, + }), + }); } describe('GPT diagnostics fact transport', () => { @@ -28,7 +35,7 @@ describe('GPT diagnostics fact transport', () => { const received: number[] = []; const release = buffer.activate((item) => { - received.push((item.slot as { index: number }).index); + received.push(Number(item.slot.elementId?.slice('slot-'.length))); }); expect(received).toHaveLength(512); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index 8f9ebc7f2..b0ed27ca8 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -5,24 +5,20 @@ import { createGptDiagnosticsFactBuffer } from '../../../src/integrations/gpt_di import { createGptDiagnosticsRuntime } from '../../../src/integrations/gpt_diagnostics'; import { GPT_DIAGNOSTICS_HOST_ID } from '../../../src/integrations/gpt_diagnostics/overlay'; -interface FakeSlot { - getSlotElementId(): string; - getAdUnitPath(): string; -} - -function slot(id: string): FakeSlot { +function slot(id: string): GoogletagDiagnosticsFact['slot'] { return Object.freeze({ - getSlotElementId: () => id, - getAdUnitPath: () => `/example/site/${id}`, + token: Object.freeze(Object.create(null) as object), + elementId: id, + adUnitPath: `/example/site/${id}`, }); } function fact( kind: GoogletagDiagnosticsFact['kind'], - observedSlot: object, + observedSlot: GoogletagDiagnosticsFact['slot'], fields: Partial = {} ): Readonly { - return Object.freeze({ kind, slot: observedSlot, ...fields }); + return Object.freeze({ kind, observedAtMs: 1, slot: observedSlot, ...fields }); } beforeEach(() => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts index ca934a177..b7ce96bc5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; -import type { GoogletagDiagnosticsFact } from '../../../src/adapters/googletag'; +import type { + GoogletagDiagnosticsFact, + GoogletagDiagnosticsSlotSnapshot, +} from '../../../src/adapters/googletag'; import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, } from '../../../src/integrations/gpt_diagnostics/observer'; -import type { GptDiagnosticsSlotLike } from '../../../src/integrations/gpt_diagnostics/store'; function fakeStore(): GptDiagnosticsObserverStore { return { @@ -19,30 +21,31 @@ function fakeStore(): GptDiagnosticsObserverStore { }; } -function fakeSlot(): GptDiagnosticsSlotLike { +function fakeSlot(): GoogletagDiagnosticsSlotSnapshot { return Object.freeze({ - getSlotElementId: () => 'ad-slot-example', - getAdUnitPath: () => '/example/site/banner', + token: Object.freeze(Object.create(null) as object), + elementId: 'ad-slot-example', + adUnitPath: '/example/site/banner', }); } function fact( kind: GoogletagDiagnosticsFact['kind'], - slot: object, + slot: GoogletagDiagnosticsFact['slot'], fields: Partial = {} ): Readonly { - return Object.freeze({ kind, slot, ...fields }); + return Object.freeze({ kind, observedAtMs: 1, slot, ...fields }); } describe('GptDiagnosticsObserver', () => { - it('starts exactly once without reading or mutating any browser global', () => { + it('does not claim GPT observation merely because the diagnostics module activated', () => { const store = fakeStore(); const observer = new GptDiagnosticsObserver(store); observer.start(); observer.start(); - expect(store.markGptObserved).toHaveBeenCalledOnce(); + expect(store.markGptObserved).not.toHaveBeenCalled(); }); it('consumes all six normalized adapter facts', () => { @@ -65,17 +68,36 @@ describe('GptDiagnosticsObserver', () => { observer.consume(fact('slotVisibilityChanged', slot, { inViewPercentage: 42 })); expect(store.markGptObserved).toHaveBeenCalledOnce(); - expect(store.recordSlotRequested).toHaveBeenCalledWith(slot); - expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot); - expect(store.recordSlotRenderEnded).toHaveBeenCalledWith(slot, { - isEmpty: false, - size: [300, 250], - isBackfill: true, - slotContentChanged: false, + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotResponseReceived).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotRenderEnded).toHaveBeenCalledWith( + slot, + { + isEmpty: false, + size: [300, 250], + isBackfill: true, + slotContentChanged: false, + }, + 1 + ); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); + expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot, 1); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42, 1); + }); + + it('passes the immutable adapter callback timestamp through to every store mutation', () => { + const store = fakeStore(); + const observer = new GptDiagnosticsObserver(store); + const slot = fakeSlot(); + const timestamped = Object.freeze({ + kind: 'slotRequested' as const, + slot, + observedAtMs: 123.5, }); - expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); - expect(store.recordImpressionViewable).toHaveBeenCalledWith(slot); - expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(slot, 42); + + observer.consume(timestamped as Readonly); + + expect(store.recordSlotRequested).toHaveBeenCalledWith(slot, 123.5); }); it('records a malformed visibility fact as unmatched instead of dropping its coverage', () => { @@ -84,7 +106,7 @@ describe('GptDiagnosticsObserver', () => { observer.consume(fact('slotVisibilityChanged', fakeSlot())); - expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN); + expect(store.recordSlotVisibilityChanged).toHaveBeenCalledWith(expect.any(Object), NaN, 1); }); it('contains store and logger failures without interrupting later facts', () => { @@ -104,6 +126,6 @@ describe('GptDiagnosticsObserver', () => { expect(() => observer.consume(fact('slotOnload', slot))).not.toThrow(); expect(logger.warn).toHaveBeenCalledOnce(); - expect(store.recordSlotOnload).toHaveBeenCalledWith(slot); + expect(store.recordSlotOnload).toHaveBeenCalledWith(slot, 1); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 7c176365d..6cb6b0b9e 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -82,6 +82,22 @@ describe('GptDiagnosticsStore', () => { assertCoverageEquation(store); }); + it('uses adapter callback times even when buffered delivery occurs much later', () => { + const store = new GptDiagnosticsStore({ now: () => 9_999 }); + const slot = fakeSlot('buffered-slot'); + + store.recordSlotRequested(slot, 10); + store.recordSlotResponseReceived(slot, 25); + store.recordSlotRenderEnded(slot, { isEmpty: false }, 30); + + expect(store.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, + }); + }); + it('matches load and viewability after a render with unknown fill state', () => { let now = 1; const store = new GptDiagnosticsStore({ now: () => now }); diff --git a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts index db4dcbb90..2557aa19c 100644 --- a/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/diagnostics.test.ts @@ -152,6 +152,20 @@ describe('kernel diagnostics bus', () => { bus.dispose(); }); + it('rejects frozen functions and exotic objects instead of transporting capabilities', () => { + const bus = createDiagnosticsBus({ manifest: manifest([]) }); + const callable = Object.freeze(() => undefined); + const exotic = Object.freeze(new (class PublisherSlot {})()); + + expect( + bus.publish(Object.freeze({ kind: 'gpt', slot: callable }) as DiagnosticsObservation) + ).toBe(false); + expect( + bus.publish(Object.freeze({ kind: 'gpt', slot: exotic }) as DiagnosticsObservation) + ).toBe(false); + bus.dispose(); + }); + it('commits to the private core observer before asynchronous module delivery', () => { vi.useFakeTimers(); const order: string[] = []; From 6f26c2f71a0d6be20ba8bdf016adbe573e7a8453 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:55:07 -0700 Subject: [PATCH 395/494] Cancel owned GPT diagnostics frames --- .../integrations/gpt_diagnostics/badges.ts | 63 +++++++++++-- .../integrations/gpt_diagnostics/binding.ts | 63 +++++++++++-- .../integrations/gpt_diagnostics/overlay.ts | 89 +++++++++++++++---- .../gpt_diagnostics/badges.test.ts | 70 ++++++++++++++- .../gpt_diagnostics/binding.test.ts | 64 ++++++++++++- .../gpt_diagnostics/overlay.test.ts | 66 ++++++++++++-- 6 files changed, 370 insertions(+), 45 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts index 970bf48e4..cbce0213c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts @@ -29,15 +29,21 @@ const BADGE_EDGE_GUTTER_PX = 4; interface BadgeOptions { window?: BadgeWindow | undefined; document?: Document | undefined; - scheduleFrame?: ((callback: () => void) => void) | undefined; + scheduleFrame?: ((callback: () => void) => () => void) | undefined; } -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); +function defaultScheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const frame = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(frame); } + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } function intersectsViewport(rectangle: DOMRect, window: Window): boolean { @@ -110,7 +116,7 @@ export class GptDiagnosticsBadgeManager { private readonly bindings: BadgeBindings; private readonly window: BadgeWindow; private readonly document: Document; - private readonly scheduleFrame: (callback: () => void) => void; + private readonly scheduleFrame: (callback: () => void) => () => void; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); @@ -118,6 +124,7 @@ export class GptDiagnosticsBadgeManager { private layer: HTMLElement | undefined; private mutationObserver?: MutationObserver; private resizeObserver?: ResizeObserver; + private cancelScheduledUpdate: (() => void) | undefined; private scheduled = false; private destroyed = false; @@ -192,6 +199,14 @@ export class GptDiagnosticsBadgeManager { destroy(): void { if (this.destroyed) return; this.destroyed = true; + const cancelUpdate = this.cancelScheduledUpdate; + this.cancelScheduledUpdate = undefined; + this.scheduled = false; + try { + cancelUpdate?.(); + } catch { + // Continue releasing every independently owned badge resource. + } this.unsubscribeStore(); this.unsubscribeBindings(); this.window.removeEventListener('scroll', this.scheduleUpdate); @@ -205,10 +220,40 @@ export class GptDiagnosticsBadgeManager { private readonly scheduleUpdate = (): void => { if (this.destroyed || this.scheduled) return; this.scheduled = true; - this.scheduleFrame(() => { + let active = true; + let cancelFrame: (() => void) | undefined; + const run = (): void => { + if (!active) return; + active = false; + this.cancelScheduledUpdate = undefined; + try { + cancelFrame?.(); + } catch { + // A completed frame remains authoritative when scheduler cleanup fails. + } this.scheduled = false; this.update(); - }); + }; + try { + cancelFrame = this.scheduleFrame(run); + if (typeof cancelFrame !== 'function') { + throw new TypeError('Invalid badge frame scheduler'); + } + if (active) { + this.cancelScheduledUpdate = (): void => { + if (!active) return; + active = false; + this.scheduled = false; + cancelFrame?.(); + }; + } else { + cancelFrame(); + } + } catch { + active = false; + this.cancelScheduledUpdate = undefined; + this.scheduled = false; + } }; private refreshSlots(): void { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts index 1a2cc048f..5ba799f9f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts @@ -16,7 +16,7 @@ type BindingWindow = Window & { interface BindingOptions { document?: Document | undefined; window?: BindingWindow | undefined; - scheduleFrame?: ((callback: () => void) => void) | undefined; + scheduleFrame?: ((callback: () => void) => () => void) | undefined; } export interface GptDiagnosticsBindingView { @@ -27,12 +27,18 @@ export interface GptDiagnosticsBindingView { type BindingListener = () => void; -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); +function defaultScheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const frame = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(frame); } + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } function isVisibleInViewport(element: HTMLElement, window: BindingWindow): boolean { @@ -84,12 +90,13 @@ export class GptDiagnosticsBindingManager { private readonly store: BindingStore; private readonly document: Document; private readonly window: BindingWindow; - private readonly scheduleFrame: (callback: () => void) => void; + private readonly scheduleFrame: (callback: () => void) => () => void; private readonly bindings = new Map(); private readonly listeners = new Set(); private readonly slotElementIds = new Set(); private readonly unsubscribeStore: () => void; private mutationObserver?: MutationObserver; + private cancelScheduledRefresh: (() => void) | undefined; private refreshScheduled = false; private destroyed = false; @@ -155,6 +162,14 @@ export class GptDiagnosticsBindingManager { destroy(): void { if (this.destroyed) return; this.destroyed = true; + const cancelRefresh = this.cancelScheduledRefresh; + this.cancelScheduledRefresh = undefined; + this.refreshScheduled = false; + try { + cancelRefresh?.(); + } catch { + // Continue releasing every independently owned binding resource. + } this.unsubscribeStore(); this.mutationObserver?.disconnect(); this.window.removeEventListener('scroll', this.scheduleRefresh); @@ -166,10 +181,40 @@ export class GptDiagnosticsBindingManager { private readonly scheduleRefresh = (): void => { if (this.destroyed || this.refreshScheduled) return; this.refreshScheduled = true; - this.scheduleFrame(() => { + let active = true; + let cancelFrame: (() => void) | undefined; + const run = (): void => { + if (!active) return; + active = false; + this.cancelScheduledRefresh = undefined; + try { + cancelFrame?.(); + } catch { + // A completed frame remains authoritative when scheduler cleanup fails. + } this.refreshScheduled = false; this.refresh(); - }); + }; + try { + cancelFrame = this.scheduleFrame(run); + if (typeof cancelFrame !== 'function') { + throw new TypeError('Invalid binding frame scheduler'); + } + if (active) { + this.cancelScheduledRefresh = (): void => { + if (!active) return; + active = false; + this.refreshScheduled = false; + cancelFrame?.(); + }; + } else { + cancelFrame(); + } + } catch { + active = false; + this.cancelScheduledRefresh = undefined; + this.refreshScheduled = false; + } }; private resolveBinding( diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts index 22614d74c..15594e3fa 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts @@ -25,7 +25,7 @@ type OverlayWindow = Window & { interface OverlayOptions { window?: OverlayWindow | undefined; document?: Document | undefined; - scheduleFrame?: ((callback: () => void) => void) | undefined; + scheduleFrame?: ((callback: () => void) => () => void) | undefined; onExport?: (() => void) | undefined; onShadowRoot?: ((root: ShadowRoot) => void) | undefined; onBadgeLayerChange?: ((layer: HTMLElement | undefined) => void) | undefined; @@ -98,12 +98,18 @@ const PANEL_STYLES = ` } `; -function defaultScheduleFrame(callback: () => void): void { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); +function defaultScheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const frame = requestAnimationFrame(() => callback()); + return () => cancelAnimationFrame(frame); } + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } function latestCycle( @@ -189,7 +195,7 @@ export class GptDiagnosticsOverlay { private readonly bindings: OverlayBindings; private readonly window: OverlayWindow; private readonly document: Document; - private readonly scheduleFrame: (callback: () => void) => void; + private readonly scheduleFrame: (callback: () => void) => () => void; private readonly onExport: () => void; private readonly onShadowRoot: ((root: ShadowRoot) => void) | undefined; private readonly onBadgeLayerChange: ((layer: HTMLElement | undefined) => void) | undefined; @@ -198,6 +204,7 @@ export class GptDiagnosticsOverlay { private host: HTMLElement | undefined; private panel: HTMLElement | undefined; private lifecycleObserver?: MutationObserver; + private readonly cancelScheduledFrames = new Set<() => void>(); private visualReady = false; private mountWaitStarted = false; private renderScheduled = false; @@ -240,6 +247,14 @@ export class GptDiagnosticsOverlay { if (this.destroyed) return; this.destroyed = true; this.dismissed = true; + for (const cancelFrame of [...this.cancelScheduledFrames]) { + this.cancelScheduledFrames.delete(cancelFrame); + try { + cancelFrame(); + } catch { + // Continue releasing every independently owned overlay resource. + } + } this.unsubscribeStore(); this.unsubscribeBindings(); this.lifecycleObserver?.disconnect(); @@ -262,8 +277,8 @@ export class GptDiagnosticsOverlay { this.mountWaitStarted = true; this.document.removeEventListener('readystatechange', this.handleReadyStateChange); - this.scheduleFrame(() => { - this.scheduleFrame(() => { + this.scheduleOwnedFrame(() => { + this.scheduleOwnedFrame(() => { this.visualReady = true; if (!this.dismissed) this.mount(); }); @@ -331,10 +346,14 @@ export class GptDiagnosticsOverlay { this.hostCollision = false; } this.remountScheduled = true; - this.scheduleFrame(() => { + if ( + !this.scheduleOwnedFrame(() => { + this.remountScheduled = false; + this.mount(); + }) + ) { this.remountScheduled = false; - this.mount(); - }); + } }); this.lifecycleObserver.observe(this.document.documentElement, { childList: true, @@ -345,10 +364,50 @@ export class GptDiagnosticsOverlay { private scheduleRender(): void { if (this.destroyed || this.renderScheduled) return; this.renderScheduled = true; - this.scheduleFrame(() => { + if ( + !this.scheduleOwnedFrame(() => { + this.renderScheduled = false; + this.render(); + }) + ) { this.renderScheduled = false; - this.render(); - }); + } + } + + private scheduleOwnedFrame(callback: () => void): boolean { + if (this.destroyed) return false; + let active = true; + let cancelFrame: (() => void) | undefined; + let release: (() => void) | undefined; + const run = (): void => { + if (!active) return; + active = false; + if (release) this.cancelScheduledFrames.delete(release); + try { + cancelFrame?.(); + } catch { + // A completed frame remains authoritative when scheduler cleanup fails. + } + if (!this.destroyed) callback(); + }; + try { + cancelFrame = this.scheduleFrame(run); + if (typeof cancelFrame !== 'function') { + throw new TypeError('Invalid overlay frame scheduler'); + } + release = (): void => { + if (!active) return; + active = false; + cancelFrame?.(); + }; + if (active) this.cancelScheduledFrames.add(release); + else cancelFrame(); + return true; + } catch { + active = false; + if (release) this.cancelScheduledFrames.delete(release); + return false; + } } private render(): void { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts index 712669973..758dfb739 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts @@ -64,6 +64,16 @@ function runFrame(frames: Array<() => void>): void { frame(); } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + beforeEach(() => { document.body.replaceChildren(); Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); @@ -105,7 +115,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -181,7 +191,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -218,7 +228,7 @@ describe('GptDiagnosticsBadgeManager', () => { const layer = document.createElement('div'); document.body.append(layer); const manager = new GptDiagnosticsBadgeManager(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); manager.setLayer(layer); runFrame(frames); @@ -257,7 +267,7 @@ describe('GptDiagnosticsBadgeManager', () => { MutationObserver: undefined, ResizeObserver: undefined, }), - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); expect(() => { @@ -266,4 +276,56 @@ describe('GptDiagnosticsBadgeManager', () => { }).not.toThrow(); manager.destroy(); }); + + it('cancels a pending badge update on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const layer = document.createElement('div'); + document.body.append(layer); + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return cancel; + }, + }); + const update = vi.spyOn(manager, 'update'); + manager.setLayer(layer); + + manager.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(update).not.toHaveBeenCalled(); + }); + + it('runs one scheduled badge callback at most once', () => { + const frames: Array<() => void> = []; + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return vi.fn(); + }, + }); + const update = vi.spyOn(manager, 'update'); + manager.setLayer(document.createElement('div')); + + frames[0]?.(); + frames[0]?.(); + + expect(update).toHaveBeenCalledOnce(); + manager.destroy(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const manager = new GptDiagnosticsBadgeManager(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: () => cancel, + }); + manager.setLayer(document.createElement('div')); + + expect(() => manager.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts index 67b57b77d..66ce259f7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts @@ -33,13 +33,23 @@ function createStore(): GptDiagnosticsStore { function createManager( store: GptDiagnosticsStore, - scheduleFrame?: (callback: () => void) => void + scheduleFrame?: (callback: () => void) => () => void ): GptDiagnosticsBindingManager { const manager = new GptDiagnosticsBindingManager(store, { scheduleFrame }); managers.push(manager); return manager; } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + function setRectangle( element: HTMLElement, rectangle: { top: number; left: number; width: number; height: number } @@ -258,7 +268,7 @@ describe('GptDiagnosticsBindingManager', () => { document.body.append(element); const store = createStore(); store.recordSlotRequested(fakeSlot('observed')); - const manager = createManager(store, (callback) => frames.push(callback)); + const manager = createManager(store, queueFrame(frames)); const unrelated = document.createElement('div'); unrelated.id = 'unrelated'; @@ -284,7 +294,7 @@ describe('GptDiagnosticsBindingManager', () => { it('coalesces store-driven refreshes to one animation frame', () => { const scheduled: Array<() => void> = []; const store = createStore(); - const manager = createManager(store, (callback) => scheduled.push(callback)); + const manager = createManager(store, queueFrame(scheduled)); const listener = vi.fn(); manager.subscribe(listener); const slot = fakeSlot('scheduled'); @@ -301,4 +311,52 @@ describe('GptDiagnosticsBindingManager', () => { reason: 'missing_element', }); }); + + it('cancels a pending refresh on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const store = createStore(); + const manager = createManager(store, (callback) => { + frames.push(callback); + return cancel; + }); + const listener = vi.fn(); + manager.subscribe(listener); + store.recordSlotRequested(fakeSlot('pending-destroy')); + + manager.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('runs one scheduled refresh callback at most once', () => { + const frames: Array<() => void> = []; + const store = createStore(); + const manager = createManager(store, (callback) => { + frames.push(callback); + return vi.fn(); + }); + const listener = vi.fn(); + manager.subscribe(listener); + store.recordSlotRequested(fakeSlot('once')); + + frames[0]?.(); + frames[0]?.(); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const store = createStore(); + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const manager = createManager(store, () => cancel); + store.recordSlotRequested(fakeSlot('hostile-cancel')); + + expect(() => manager.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts index 8af37ea93..c0f2f1878 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts @@ -59,6 +59,16 @@ function runNextFrame(frames: Array<() => void>): void { frame(); } +function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { + return (callback) => { + frames.push(callback); + return () => { + const index = frames.indexOf(callback); + if (index >= 0) frames.splice(index, 1); + }; + }; +} + beforeEach(() => { document.body.replaceChildren(); vi.spyOn(document, 'readyState', 'get').mockReturnValue('complete'); @@ -76,7 +86,7 @@ describe('GptDiagnosticsOverlay', () => { store.recordSlotRequested(slot('early-slot')); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -140,7 +150,7 @@ describe('GptDiagnosticsOverlay', () => { const exportSnapshot = vi.fn(); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onExport: exportSnapshot, onShadowRoot: (createdRoot) => { root = createdRoot; @@ -215,7 +225,7 @@ describe('GptDiagnosticsOverlay', () => { document.body.append(publisherElement); const warn = vi.spyOn(log, 'warn'); const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), }); runNextFrame(frames); runNextFrame(frames); @@ -244,7 +254,7 @@ describe('GptDiagnosticsOverlay', () => { store.recordSlotRequested(diagnosticSlot); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -270,7 +280,7 @@ describe('GptDiagnosticsOverlay', () => { const store = new GptDiagnosticsStore(); let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -304,4 +314,50 @@ describe('GptDiagnosticsOverlay', () => { expect(document.querySelectorAll(`#${GPT_DIAGNOSTICS_HOST_ID}`)).toHaveLength(1); overlay.destroy(); }); + + it('cancels a pending mount frame on destroy and suppresses a hostile late callback', () => { + const frames: Array<() => void> = []; + const cancel = vi.fn(); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return cancel; + }, + }); + + overlay.destroy(); + frames[0]?.(); + + expect(cancel).toHaveBeenCalledOnce(); + expect(frames).toHaveLength(1); + expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).toBeNull(); + }); + + it('runs one scheduled mount callback at most once', () => { + const frames: Array<() => void> = []; + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: (callback) => { + frames.push(callback); + return vi.fn(); + }, + }); + + frames[0]?.(); + frames[0]?.(); + + expect(frames).toHaveLength(2); + overlay.destroy(); + }); + + it('isolates a hostile frame cancellation during destroy', () => { + const cancel = vi.fn(() => { + throw new Error('cancel failed'); + }); + const overlay = new GptDiagnosticsOverlay(new GptDiagnosticsStore(), new FakeBindings(), { + scheduleFrame: () => cancel, + }); + + expect(() => overlay.destroy()).not.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + }); }); From 7469a510cacc561405bb0952927348077a046f4a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:55:12 -0700 Subject: [PATCH 396/494] Clean diagnostics directives through server transport --- .../trusted-server-core/src/html_processor.rs | 22 ++- .../src/integrations/gpt_diagnostics.rs | 26 +++ .../integrations/gpt_diagnostics_bootstrap.js | 40 +++- crates/trusted-server-core/src/publisher.rs | 187 ++++++++++++++++++ .../gpt_diagnostics/bootstrap.test.ts | 59 +++++- 5 files changed, 324 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 6728f9364..9047a7db3 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -333,6 +333,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // The server has already interpreted and removed the reserved + // directive. Its external cleanup asset only updates the + // browser-visible URL and must run before publisher/core code. + if let Some(cleanup_tag) = gpt_diagnostics + .as_ref() + .and_then(GptDiagnosticsRequestDecision::url_cleanup_script_tag) + { + snippet.push_str(&cleanup_tag); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -867,6 +876,7 @@ mod tests { let processed = String::from_utf8(output).expect("should produce valid UTF-8"); let bundle_marker = "id=\"trustedserver-js\""; let diagnostics_marker = "tsjs-gpt_diagnostics.min.js"; + let cleanup_marker = "tsjs-gpt_diagnostics-bootstrap.min.js"; assert_eq!( processed.matches("__tsjs_gpt_diagnostics_active").count(), @@ -883,6 +893,10 @@ mod tests { 1, "should inject one standalone diagnostics module" ); + assert_eq!(processed.matches(cleanup_marker).count(), 1); + let cleanup_index = processed + .find(cleanup_marker) + .expect("should include the request-scoped cleanup asset"); let bundle_index = processed .find(bundle_marker) .expect("should include immediate TSJS bundle"); @@ -890,8 +904,12 @@ mod tests { .find(diagnostics_marker) .expect("should include standalone diagnostics module"); assert!( - bundle_index < diagnostics_index, - "should load diagnostics after core" + cleanup_index < bundle_index && bundle_index < diagnostics_index, + "cleanup must be an external CSP-compatible script before publisher/core work" + ); + assert!( + !processed.contains("", + GPT_DIAGNOSTICS_BOOTSTRAP_FILENAME + ) + }) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -174,6 +190,7 @@ pub fn prepare_request( let mut decision = GptDiagnosticsRequestDecision { reserved_directive: had_reserved_query, + cleanup_browser_url: eligible_navigation && had_reserved_query, ..GptDiagnosticsRequestDecision::default() }; if integration_enabled && eligible_navigation && had_reserved_query { @@ -426,6 +443,14 @@ mod tests { ); assert_eq!(request.headers()[header::COOKIE], "other=value"); assert_eq!(decision.boot_config_json(), r#"{"active":true}"#); + assert_eq!( + decision.url_cleanup_script_tag(), + Some( + "" + .to_owned() + ), + "a server-consumed directive should authorize one external cleanup asset" + ); } #[test] @@ -453,6 +478,7 @@ mod tests { ); let decision = prepare_request(&settings(true), &mut active).expect("should prepare"); assert!(decision.active()); + assert_eq!(decision.url_cleanup_script_tag(), None); assert_eq!(active.headers()[header::COOKIE], "other=value"); let mut duplicate = navigation( diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js index dde2197f8..3cbb36163 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js @@ -1,4 +1,36 @@ -// GPT diagnostics activation is server-owned and is transported only through -// the validated, frozen diagnostics boot value. This intentionally has no -// browser-side activation behavior and remains only until the wiring cutover -// removes the superseded asset. +// Request-scoped URL cleanup only. The server injects this asset exactly when +// it has already consumed and stripped at least one reserved directive. +(function () { + "use strict"; + + try { + var href = String(location.href); + var hashIndex = href.indexOf("#"); + var hash = hashIndex < 0 ? "" : href.slice(hashIndex); + var beforeHash = hashIndex < 0 ? href : href.slice(0, hashIndex); + var queryIndex = beforeHash.indexOf("?"); + if (queryIndex < 0) return; + + var pairs = beforeHash.slice(queryIndex + 1).split("&"); + var retained = []; + var removed = false; + for (var index = 0; index < pairs.length; index += 1) { + var pair = pairs[index]; + var equalsIndex = pair.indexOf("="); + var name = equalsIndex < 0 ? pair : pair.slice(0, equalsIndex); + if (name === "ts_console") { + removed = true; + } else { + retained.push(pair); + } + } + if (!removed) return; + + var cleanHref = beforeHash.slice(0, queryIndex); + var retainedQuery = retained.join("&"); + if (retainedQuery !== "") cleanHref += "?" + retainedQuery; + history.replaceState(history.state, "", cleanHref + hash); + } catch (_) { + // Browser-visible cleanup cannot affect diagnostics or publisher code. + } +})(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4595166dd..c3ebfb314 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -306,6 +306,18 @@ pub fn handle_tsjs_dynamic( } let filename = &path[PREFIX.len()..]; + if filename == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_BOOTSTRAP_FILENAME { + let mut response = serve_static_with_etag( + crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_BOOTSTRAP_SOURCE, + req, + "application/javascript; charset=utf-8", + ); + response + .headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + return Ok(response); + } + if UNIFIED_FILENAMES.contains(&filename) { // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); @@ -5313,6 +5325,73 @@ mod tests { .expect("should proxy publisher request") } + struct TsConsolePipelineResult { + response: Response, + origin_uri: String, + outbound_cookie: Option, + } + + async fn run_ts_console_pipeline( + method: Method, + destination: &str, + uri: &str, + cookie: Option<&str>, + ) -> TsConsolePipelineResult { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) + .expect("should enable diagnostics"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let mut request = Request::builder() + .method(method.clone()) + .uri(uri) + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", destination); + if let Some(cookie) = cookie { + request = request.header(header::COOKIE, cookie); + } + let request = request + .body(EdgeBody::empty()) + .expect("should build diagnostics pipeline request"); + let publisher_response = run_publisher_proxy(&settings, &services, request).await; + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let response = buffer_publisher_response_async( + publisher_response, + &method, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer diagnostics pipeline response"); + let outbound_cookie = stub.recorded_request_headers().first().and_then(|headers| { + headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(header::COOKIE.as_str())) + .map(|(_, value)| value.clone()) + }); + TsConsolePipelineResult { + response, + origin_uri: stub + .recorded_request_uris() + .into_iter() + .next() + .expect("should forward one origin request"), + outbound_cookie, + } + } + mod ssat_cache_policy_tests { use super::*; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; @@ -6098,6 +6177,96 @@ mod tests { assert!(!headers.contains_key("surrogate-control")); } + #[tokio::test] + async fn ts_console_publisher_pipeline_duplicate_and_invalid_fail_closed_but_clean_url() { + for uri in [ + "https://publisher.example/article?keep=a%2Fb&ts_console=1&ts_console=true", + "https://publisher.example/article?ts_console=True&keep=a%2Fb", + ] { + let result = run_ts_console_pipeline( + Method::GET, + "document", + uri, + Some("__Host-ts-console=1; publisher=value"), + ) + .await; + let body = response_body_string(result.response); + + assert_eq!( + result.origin_uri, + "https://origin.test-publisher.com/article?keep=a%2Fb" + ); + assert_eq!(result.outbound_cookie.as_deref(), Some("publisher=value")); + assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); + assert_eq!( + body.matches("tsjs-gpt_diagnostics-bootstrap.min.js") + .count(), + 1 + ); + } + } + + #[tokio::test] + async fn ts_console_publisher_pipeline_cookie_session_and_disable_are_exact() { + let active = run_ts_console_pipeline( + Method::GET, + "document", + "https://publisher.example/article?keep=%2F", + Some("publisher=value; __Host-ts-console=1"), + ) + .await; + let active_body = response_body_string(active.response); + assert_eq!(active.outbound_cookie.as_deref(), Some("publisher=value")); + assert!(active_body.contains("tsjs-gpt_diagnostics.min.js")); + assert!(!active_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + + let disabled = run_ts_console_pipeline( + Method::GET, + "document", + "https://publisher.example/article?ts_console=false&keep=%2F", + Some("publisher=value; __Host-ts-console=1"), + ) + .await; + assert_eq!( + disabled.response.headers()[header::SET_COOKIE], + "__Host-ts-console=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + ); + assert_eq!( + disabled.origin_uri, + "https://origin.test-publisher.com/article?keep=%2F" + ); + let disabled_body = response_body_string(disabled.response); + assert!(!disabled_body.contains("tsjs-gpt_diagnostics.min.js")); + assert_eq!( + disabled_body + .matches("tsjs-gpt_diagnostics-bootstrap.min.js") + .count(), + 1 + ); + } + + #[tokio::test] + async fn ts_console_publisher_pipeline_method_and_document_ineligibility_stay_inert() { + for (method, destination) in [(Method::POST, "document"), (Method::GET, "script")] { + let result = run_ts_console_pipeline( + method, + destination, + "https://publisher.example/article?keep=%2F&ts_console=1", + Some("publisher=value; __Host-ts-console=1"), + ) + .await; + assert_eq!( + result.origin_uri, + "https://origin.test-publisher.com/article?keep=%2F" + ); + assert_eq!(result.outbound_cookie.as_deref(), Some("publisher=value")); + assert!(!result.response.headers().contains_key(header::SET_COOKIE)); + let body = response_body_string(result.response); + assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); + assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); + } + } + #[tokio::test] async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); @@ -7112,6 +7281,24 @@ mod tests { ); } + #[test] + fn ts_console_dynamic_serves_the_non_authoritative_cleanup_asset() { + let settings = create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let req = Request::builder() + .uri("https://publisher.example/static/tsjs=tsjs-gpt_diagnostics-bootstrap.min.js") + .body(EdgeBody::empty()) + .expect("should build cleanup asset request"); + + let response = handle_tsjs_dynamic(&req, ®istry).expect("should serve cleanup asset"); + let source = response_body_string(response); + + assert!(source.contains("history.replaceState")); + assert!(source.contains("ts_console")); + assert!(!source.contains("sessionStorage")); + assert!(!source.contains("__tsjs_gpt_diagnostics_active")); + } + #[test] fn parse_single_module_filename_extracts_known_id() { assert_eq!( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts index c40af3e56..741b70459 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; const bootstrapPath = resolve( process.cwd(), @@ -9,10 +9,61 @@ const bootstrapPath = resolve( ); const bootstrapSource = readFileSync(bootstrapPath, 'utf8'); describe('GPT diagnostics activation ownership', () => { - it('leaves no browser-owned query, storage, history, or activation-flag bootstrap', () => { - expect(bootstrapSource).not.toMatch(/ts_console/); + it('only performs one server-authorized raw URL cleanup without publishing authority', () => { + const replaceState = vi.fn(); + const state = Object.freeze({ publisher: 'state' }); + const location = Object.freeze({ + href: 'https://publisher.example/a%2Fb?keep=%2F&ts_console=1&space=a+b&ts_console=bogus#frag%20x', + }); + const history = Object.freeze({ replaceState, state }); + + Function('location', 'history', bootstrapSource)(location, history); + + expect(replaceState).toHaveBeenCalledExactlyOnceWith( + state, + '', + 'https://publisher.example/a%2Fb?keep=%2F&space=a+b#frag%20x' + ); expect(bootstrapSource).not.toMatch(/sessionStorage|localStorage/); - expect(bootstrapSource).not.toMatch(/replaceState/); expect(bootstrapSource).not.toMatch(/__tsjs_gpt_diagnostics_active/); + expect(bootstrapSource).not.toMatch(/window\s*\[/); + }); + + it('contains replaceState failure and preserves an unrelated URL without a call', () => { + const replaceState = vi.fn(() => { + throw new Error('fictional history failure'); + }); + expect(() => + Function('location', 'history', bootstrapSource)( + Object.freeze({ href: 'https://publisher.example/?ts_console=false#kept' }), + Object.freeze({ replaceState, state: null }) + ) + ).not.toThrow(); + expect(replaceState).toHaveBeenCalledOnce(); + + replaceState.mockClear(); + Function('location', 'history', bootstrapSource)( + Object.freeze({ href: 'https://publisher.example/?contest_console=1#kept' }), + Object.freeze({ replaceState, state: null }) + ); + expect(replaceState).not.toHaveBeenCalled(); + }); + + it.each([ + ['https://publisher.example/a?ts_console=1', 'https://publisher.example/a'], + ['https://publisher.example/a?ts_console=1&', 'https://publisher.example/a'], + [ + 'https://publisher.example/a?&ts_console=1&keep=%2F', + 'https://publisher.example/a?&keep=%2F', + ], + ])('matches the server sanitizer for empty raw query segments', (href, expected) => { + const replaceState = vi.fn(); + + Function('location', 'history', bootstrapSource)( + Object.freeze({ href }), + Object.freeze({ replaceState, state: null }) + ); + + expect(replaceState).toHaveBeenCalledExactlyOnceWith(null, '', expected); }); }); From e2b29001fcae735555479a74cb07e25408b4755c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:01:06 -0700 Subject: [PATCH 397/494] Enrich render trace from safe GPT facts --- .../trusted-server-js/lib/src/core/trace.ts | 121 +++++++++++++++++- .../lib/test/core/trace_runtime.test.ts | 105 +++++++++++++++ 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 836c7408f..934a4e32e 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -562,6 +562,27 @@ const MAX_RENDER_TRACE_NOTIFICATIONS = 200; type RenderTraceInputV1 = Omit; type RenderTraceUpdateV1 = Partial>; +/** Safe GPT fact shape admitted by the closure-private diagnostics bus. */ +export interface RenderTraceGptFactV1 extends Readonly> { + readonly kind: + | 'slotRequested' + | 'slotResponseReceived' + | 'slotRenderEnded' + | 'slotOnload' + | 'impressionViewable' + | 'slotVisibilityChanged'; + readonly slot: Readonly<{ readonly token: object; readonly elementId?: string }>; + readonly isEmpty?: boolean; + readonly inViewPercentage?: number; +} + +/** Current registered-slot identity and presentation state for one safe GPT fact. */ +export interface RenderTraceGptResolutionV1 { + readonly slotId: string; + readonly elementId?: string; + readonly visible?: boolean; +} + export interface RenderTraceRuntimeScheduler { readonly set: (callback: () => void, milliseconds: number) => unknown; readonly clear: (handle: unknown) => void; @@ -588,6 +609,10 @@ export interface RenderTraceRuntimeOwner { patch: RenderTraceUpdateV1 ) => Readonly | undefined; readonly prune: (slotId: string, sequence?: number) => boolean; + readonly observeGptFact: ( + fact: Readonly, + resolve: (elementId: string | undefined) => RenderTraceGptResolutionV1 | undefined + ) => void; readonly dispose: () => void; } @@ -935,6 +960,10 @@ export function createRenderTraceDiagnostics( const counts = new Map(); const history: Array> = []; const recordsBySequence = new Map>(); + const gptImpressions = new Map< + object, + { readonly baselineSequence: number | undefined; sequence?: number; readonly slotId: string } + >(); const subscribers = new Map(); const pendingOrder: number[] = []; const pendingBySequence = new Map(); @@ -1125,6 +1154,87 @@ export function createRenderTraceDiagnostics( return true; }; + const observeGptFact = ( + fact: Readonly, + resolve: (elementId: string | undefined) => RenderTraceGptResolutionV1 | undefined + ): void => { + if (disposed || typeof resolve !== 'function') return; + try { + const token = fact.slot.token; + if (typeof token !== 'object' || token === null || !Object.isFrozen(token)) return; + const resolution = resolve(fact.slot.elementId); + if (!resolution || typeof resolution.slotId !== 'string' || resolution.slotId === '') return; + + if (fact.kind === 'slotRequested') { + for (const [candidateToken, impression] of gptImpressions) { + if (impression.slotId === resolution.slotId) gptImpressions.delete(candidateToken); + } + if (gptImpressions.size >= MAX_RENDER_TRACE_SLOTS) { + const oldestToken = gptImpressions.keys().next().value as object | undefined; + if (oldestToken) gptImpressions.delete(oldestToken); + } + gptImpressions.set(token, { + baselineSequence: current.get(resolution.slotId)?.seq, + slotId: resolution.slotId, + }); + return; + } + + const impression = gptImpressions.get(token); + if (!impression || impression.slotId !== resolution.slotId) return; + if (fact.kind === 'slotResponseReceived') return; + if (fact.kind === 'slotRenderEnded') { + if (typeof fact.isEmpty !== 'boolean') return; + const latest = current.get(impression.slotId); + const target = + latest && latest.seq !== impression.baselineSequence + ? latest + : record({ + slotId: impression.slotId, + path: 'gam-refresh', + rendered: !fact.isEmpty, + gamEmpty: fact.isEmpty, + injected: false, + ...(resolution.elementId === undefined ? {} : { elementId: resolution.elementId }), + ...(resolution.visible === undefined + ? {} + : { visible: !fact.isEmpty && resolution.visible }), + servedFrom: 'gam', + }); + const enriched = enrich(target, { + rendered: !fact.isEmpty, + gamEmpty: fact.isEmpty, + injected: false, + ...(target.servedFrom === undefined ? { servedFrom: 'gam' as const } : {}), + ...(resolution.elementId === undefined ? {} : { elementId: resolution.elementId }), + ...(resolution.visible === undefined + ? {} + : { visible: !fact.isEmpty && resolution.visible }), + }); + impression.sequence = enriched?.seq ?? target.seq; + return; + } + + const targetSequence = impression.sequence; + if (targetSequence === undefined || current.get(impression.slotId)?.seq !== targetSequence) { + return; + } + if (fact.kind === 'impressionViewable') { + enrich(targetSequence, { visible: true }); + } else if ( + fact.kind === 'slotVisibilityChanged' && + typeof fact.inViewPercentage === 'number' && + Number.isFinite(fact.inViewPercentage) + ) { + enrich(targetSequence, { visible: fact.inViewPercentage > 0 }); + } else if (fact.kind === 'slotOnload' && resolution.visible !== undefined) { + enrich(targetSequence, { visible: resolution.visible }); + } + } catch { + // GPT diagnostics cannot affect the committed render or adapter callback. + } + }; + const api: RenderTraceDiagnostics = Object.freeze({ current: (): Readonly>> => { const snapshot = Object.create(null) as Record>; @@ -1175,10 +1285,19 @@ export function createRenderTraceDiagnostics( history.length = 0; recordsBySequence.clear(); counts.clear(); + gptImpressions.clear(); presentation.dispose(); }; - return Object.freeze({ api, diagnostics: api, record, enrich, prune, dispose }); + return Object.freeze({ + api, + diagnostics: api, + record, + enrich, + prune, + observeGptFact, + dispose, + }); } /** Short name used by the browser composition owner. */ diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index ef63e0065..bcdabc5c5 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -188,6 +188,111 @@ describe('render trace diagnostics runtime', () => { expect(owner.diagnostics.history()).toHaveLength(1); }); + it('records an unattributed GPT request as one GAM-refresh impression', () => { + const { owner } = harness(); + const token = Object.freeze(Object.create(null) as object); + const resolve = () => + Object.freeze({ slotId: 'publisher-slot', elementId: 'publisher-slot', visible: true }); + + owner.observeGptFact( + Object.freeze({ + kind: 'slotRequested', + observedAtMs: 1, + slot: Object.freeze({ token, elementId: 'publisher-slot' }), + }), + resolve + ); + owner.observeGptFact( + Object.freeze({ + kind: 'slotRenderEnded', + observedAtMs: 2, + slot: Object.freeze({ token, elementId: 'publisher-slot' }), + isEmpty: false, + }), + resolve + ); + + expect(owner.diagnostics.current()['publisher-slot']).toEqual( + expect.objectContaining({ + path: 'gam-refresh', + rendered: true, + gamEmpty: false, + injected: false, + visible: true, + servedFrom: 'gam', + }) + ); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + + it('enriches only the same GPT impression without weakening TS placement truth', () => { + const { owner } = harness(); + const token = Object.freeze(Object.create(null) as object); + const slot = Object.freeze({ token, elementId: 'ts-slot' }); + const resolve = () => Object.freeze({ slotId: 'ts-slot', elementId: 'ts-slot', visible: true }); + owner.record({ slotId: 'ts-slot', path: 'gam-refresh', rendered: false, injected: false }); + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }), resolve); + const trusted = owner.record({ + slotId: 'ts-slot', + path: 'ssat', + rendered: true, + injected: true, + servedFrom: 'pbs-cache', + }); + + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', observedAtMs: 2, slot, isEmpty: false }), + resolve + ); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', observedAtMs: 3, slot, isEmpty: true }), + resolve + ); + + expect(owner.diagnostics.history()).toHaveLength(2); + expect(owner.diagnostics.current()['ts-slot']).toEqual( + expect.objectContaining({ + seq: trusted.seq, + path: 'ssat', + rendered: true, + injected: true, + gamEmpty: true, + servedFrom: 'pbs-cache', + }) + ); + }); + + it('routes all GPT lifecycle facts and scopes visibility to the active physical request', () => { + const { owner } = harness(); + const firstToken = Object.freeze(Object.create(null) as object); + const secondToken = Object.freeze(Object.create(null) as object); + const resolve = () => Object.freeze({ slotId: 'visible-slot', visible: false }); + const fact = ( + kind: string, + token: object, + fields: Readonly> = Object.freeze({}) + ) => Object.freeze({ kind, observedAtMs: 1, slot: Object.freeze({ token }), ...fields }); + + owner.observeGptFact(fact('slotRequested', firstToken), resolve); + owner.observeGptFact(fact('slotResponseReceived', firstToken), resolve); + owner.observeGptFact(fact('slotRenderEnded', firstToken, { isEmpty: false }), resolve); + owner.observeGptFact(fact('slotOnload', firstToken), resolve); + owner.observeGptFact(fact('impressionViewable', firstToken), resolve); + expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(true); + owner.observeGptFact( + fact('slotVisibilityChanged', firstToken, { inViewPercentage: 0 }), + resolve + ); + expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(false); + + owner.observeGptFact(fact('slotRequested', secondToken), resolve); + owner.observeGptFact(fact('impressionViewable', secondToken), resolve); + expect( + owner.diagnostics.current()['visible-slot']?.visible, + 'a pre-render callback for a replacement physical request must not enrich the old impression' + ).toBe(false); + }); + it('drops the oldest of 201 pending records and cancels work on disposal', () => { const { owner, tasks, drain } = harness(); const listener = vi.fn(); From 3ba07ff523311aa143f1ea5ef754457eb1d3c625 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:03:01 -0700 Subject: [PATCH 398/494] Latch first GPT render trace terminal fact --- crates/trusted-server-js/lib/src/core/trace.ts | 9 ++++++++- .../lib/test/core/trace_runtime.test.ts | 15 +++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 934a4e32e..00daa12c2 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -962,7 +962,12 @@ export function createRenderTraceDiagnostics( const recordsBySequence = new Map>(); const gptImpressions = new Map< object, - { readonly baselineSequence: number | undefined; sequence?: number; readonly slotId: string } + { + readonly baselineSequence: number | undefined; + renderEnded?: boolean; + sequence?: number; + readonly slotId: string; + } >(); const subscribers = new Map(); const pendingOrder: number[] = []; @@ -1185,6 +1190,8 @@ export function createRenderTraceDiagnostics( if (fact.kind === 'slotResponseReceived') return; if (fact.kind === 'slotRenderEnded') { if (typeof fact.isEmpty !== 'boolean') return; + if (impression.renderEnded) return; + impression.renderEnded = true; const latest = current.get(impression.slotId); const target = latest && latest.seq !== impression.baselineSequence diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index bcdabc5c5..abded6af1 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -5,6 +5,7 @@ import { DiagnosticsSubscriberLimitError, TRACE_BADGE_CLASS, TRACE_PANEL_ID, + type RenderTraceGptFactV1, } from '../../src/core/trace'; function harness() { @@ -256,7 +257,7 @@ describe('render trace diagnostics runtime', () => { path: 'ssat', rendered: true, injected: true, - gamEmpty: true, + gamEmpty: false, servedFrom: 'pbs-cache', }) ); @@ -268,10 +269,13 @@ describe('render trace diagnostics runtime', () => { const secondToken = Object.freeze(Object.create(null) as object); const resolve = () => Object.freeze({ slotId: 'visible-slot', visible: false }); const fact = ( - kind: string, + kind: RenderTraceGptFactV1['kind'], token: object, - fields: Readonly> = Object.freeze({}) - ) => Object.freeze({ kind, observedAtMs: 1, slot: Object.freeze({ token }), ...fields }); + fields: Readonly> = Object.freeze( + {} + ) + ): Readonly => + Object.freeze({ kind, observedAtMs: 1, slot: Object.freeze({ token }), ...fields }); owner.observeGptFact(fact('slotRequested', firstToken), resolve); owner.observeGptFact(fact('slotResponseReceived', firstToken), resolve); @@ -286,11 +290,14 @@ describe('render trace diagnostics runtime', () => { expect(owner.diagnostics.current()['visible-slot']?.visible).toBe(false); owner.observeGptFact(fact('slotRequested', secondToken), resolve); + owner.observeGptFact(fact('slotRenderEnded', firstToken, { isEmpty: true }), resolve); + owner.observeGptFact(fact('impressionViewable', firstToken), resolve); owner.observeGptFact(fact('impressionViewable', secondToken), resolve); expect( owner.diagnostics.current()['visible-slot']?.visible, 'a pre-render callback for a replacement physical request must not enrich the old impression' ).toBe(false); + expect(owner.diagnostics.history()).toHaveLength(1); }); it('drops the oldest of 201 pending records and cancels work on disposal', () => { From 0e30fd3990318069c3c75f4f92ae1d1fa98efb82 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:04:16 -0700 Subject: [PATCH 399/494] Close the maximal runtime ownership gate --- .../scripts/integration-inventory-v1.d.mts | 1 + .../lib/scripts/integration-inventory-v1.mjs | 14 + .../lib/src/shared/beacon_guard.ts | 54 ++-- .../lib/test/build/release-v1.test.mjs | 27 ++ .../test/composition/maximal-runtime.test.ts | 239 ++++++++++++++++++ .../lib/test/shared/beacon_guard.test.ts | 14 + 6 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts create mode 100644 crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs create mode 100644 crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts diff --git a/crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts new file mode 100644 index 000000000..ce0f9ebe6 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.d.mts @@ -0,0 +1 @@ +export function discoverIntegrationModules(integrationsDirectory: string): string[]; diff --git a/crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs new file mode 100644 index 000000000..8737f37c0 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/integration-inventory-v1.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Discover the canonical integration bundle inventory used by build and runtime tests. */ +export function discoverIntegrationModules(integrationsDirectory) { + if (!fs.existsSync(integrationsDirectory)) return []; + return fs + .readdirSync(integrationsDirectory) + .filter((name) => { + const fullPath = path.join(integrationsDirectory, name); + return fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')); + }) + .sort(); +} diff --git a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts index 94618dc30..d7d4273e7 100644 --- a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts +++ b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts @@ -55,8 +55,12 @@ function extractUrl(input: RequestInfo | URL): string | null { */ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { let installed = false; - let originalSendBeacon: typeof navigator.sendBeacon | null = null; - let originalFetch: typeof window.fetch | null = null; + let originalSendBeacon: typeof navigator.sendBeacon | undefined; + let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let originalFetch: typeof window.fetch | undefined; + let originalFetchDescriptor: PropertyDescriptor | undefined; + let sendBeaconPatched = false; + let fetchPatched = false; const prefix = `${config.name} beacon guard`; function install(): void { @@ -74,23 +78,31 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { // --- Patch navigator.sendBeacon --- if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { - originalSendBeacon = navigator.sendBeacon.bind(navigator); + originalSendBeacon = navigator.sendBeacon; + originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + sendBeaconPatched = true; navigator.sendBeacon = function (url: string, data?: BodyInit | null): boolean { + const sendBeacon = originalSendBeacon; + if (!sendBeacon) return false; if (config.isTargetUrl(url)) { const rewritten = config.rewriteUrl(url); log.info(`${prefix}: rewriting sendBeacon`, { original: url, rewritten }); - return originalSendBeacon!(rewritten, data); + return Reflect.apply(sendBeacon, navigator, [rewritten, data]); } - return originalSendBeacon!(url, data); + return Reflect.apply(sendBeacon, navigator, [url, data]); }; } // --- Patch window.fetch --- if (typeof window.fetch === 'function') { - originalFetch = window.fetch.bind(window); + originalFetch = window.fetch; + originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchPatched = true; window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise { + const fetch = originalFetch; + if (!fetch) return Promise.reject(new TypeError('fetch is unavailable')); const url = extractUrl(input); if (url && config.isTargetUrl(url)) { @@ -100,12 +112,12 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { // If the input was a Request, create a new one with the rewritten URL if (input instanceof Request) { const newRequest = new Request(rewritten, input); - return originalFetch!(newRequest, init); + return Reflect.apply(fetch, window, [newRequest, init]); } - return originalFetch!(rewritten, init); + return Reflect.apply(fetch, window, [rewritten, init]); } - return originalFetch!(input, init); + return Reflect.apply(fetch, window, [input, init]); }; } @@ -118,14 +130,26 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { } function reset(): void { - if (originalSendBeacon && typeof navigator !== 'undefined') { - navigator.sendBeacon = originalSendBeacon; - originalSendBeacon = null; + if (sendBeaconPatched && typeof navigator !== 'undefined') { + if (originalSendBeaconDescriptor) { + Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); + } else { + Reflect.deleteProperty(navigator, 'sendBeacon'); + } } - if (originalFetch && typeof window !== 'undefined') { - window.fetch = originalFetch; - originalFetch = null; + if (fetchPatched && typeof window !== 'undefined') { + if (originalFetchDescriptor) { + Object.defineProperty(window, 'fetch', originalFetchDescriptor); + } else { + Reflect.deleteProperty(window, 'fetch'); + } } + originalSendBeacon = undefined; + originalSendBeaconDescriptor = undefined; + originalFetch = undefined; + originalFetchDescriptor = undefined; + sendBeaconPatched = false; + fetchPatched = false; installed = false; log.debug(`${prefix}: reset and uninstalled`); } diff --git a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs index 752766679..6d19c236a 100644 --- a/crates/trusted-server-js/lib/test/build/release-v1.test.mjs +++ b/crates/trusted-server-js/lib/test/build/release-v1.test.mjs @@ -16,6 +16,33 @@ const libDirectory = path.resolve(testDirectory, '../..'); const repositoryRoot = path.resolve(libDirectory, '../../..'); const bundle = (id, logical) => ({ id, bytes: Buffer.from(`${logical}${RELEASE_SENTINEL}`) }); +const EXPECTED_RELEASE_BUNDLE_ORDER = [ + 'core', + 'creative', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'gpt_diagnostics', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +]; + +test('generated release inventory pins the server bundle order', () => { + const manifest = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + + assert.deepEqual( + manifest.bundles.map(({ id }) => id), + EXPECTED_RELEASE_BUNDLE_ORDER + ); +}); + test('bundle metrics use the required five-module reference vector', () => { const metrics = JSON.parse( fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') diff --git a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts new file mode 100644 index 000000000..3a1f507f5 --- /dev/null +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -0,0 +1,239 @@ +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createNoopGoogletagAdapter, + type GoogletagDiagnosticsObserver, +} from '../../src/adapters/googletag'; +import { createNoopMessagingAdapter } from '../../src/adapters/messaging'; +import { createNoopPrebidAdapter } from '../../src/adapters/prebid'; +import { createTestBrowserRuntimeComposition } from '../../src/composition/browser'; +import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; +import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; +import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; +import { createGoogleTagManagerIntegrationRegistration } from '../../src/integrations/google_tag_manager/module'; +import { createGptIntegrationRegistration } from '../../src/integrations/gpt/module'; +import { createGptDiagnosticsIntegrationRegistration } from '../../src/integrations/gpt_diagnostics/module'; +import { createLockrIntegrationRegistration } from '../../src/integrations/lockr/module'; +import { createOsanoIntegrationRegistration } from '../../src/integrations/osano/module'; +import { createPermutiveIntegrationRegistration } from '../../src/integrations/permutive/module'; +import { createPrebidIntegrationRegistration } from '../../src/integrations/prebid/module'; +import { createSourcepointIntegrationRegistration } from '../../src/integrations/sourcepoint/module'; +import { createTestlightIntegrationRegistration } from '../../src/integrations/testlight/module'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + IntegrationRegistration, +} from '../../src/kernel/integration_registry'; +import { discoverIntegrationModules } from '../../scripts/integration-inventory-v1.mjs'; + +const TEST_RELEASE_ID = 'a'.repeat(64); + +type RegistrationFactory = (release: string) => IntegrationRegistration; + +const REGISTRATION_FACTORIES = new Map([ + ['creative', createCreativeIntegrationRegistration], + ['datadome', createDataDomeIntegrationRegistration], + ['didomi', createDidomiIntegrationRegistration], + ['google_tag_manager', createGoogleTagManagerIntegrationRegistration], + ['gpt', createGptIntegrationRegistration], + ['gpt_diagnostics', createGptDiagnosticsIntegrationRegistration], + ['lockr', createLockrIntegrationRegistration], + ['osano', createOsanoIntegrationRegistration], + ['permutive', createPermutiveIntegrationRegistration], + ['prebid', createPrebidIntegrationRegistration], + ['sourcepoint', createSourcepointIntegrationRegistration], + ['testlight', createTestlightIntegrationRegistration], +]); + +function generatedIntegrationIds(): readonly string[] { + return Object.freeze(discoverIntegrationModules(path.resolve(process.cwd(), 'src/integrations'))); +} + +function tracedRegistration( + registration: IntegrationRegistration, + events: string[] +): IntegrationRegistration { + return Object.freeze({ + id: registration.id, + release: registration.release, + prepare: async (context: IntegrationPrepareContext) => { + events.push(`prepare:${registration.id}`); + const prepared = await registration.prepare(context); + return Object.freeze({ + activate: (activationContext: IntegrationActivationContext): void => { + events.push(`activate:${registration.id}`); + activationContext.onDispose(() => events.push(`dispose:${registration.id}`)); + prepared.activate(activationContext); + }, + }); + }, + }); +} + +function integrationConfig(id: string): unknown { + if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); + if (id === 'gpt') return Object.freeze({}); + if (id === 'prebid') { + return Object.freeze({ + clientSideBidders: Object.freeze([]), + excludedGamAdUnitPathSuffixes: Object.freeze([]), + }); + } + if (id === 'sourcepoint') return Object.freeze({ rewriteSdk: true }); + return undefined; +} + +describe('generated maximal browser runtime transaction', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('owns all server bundles once and disposes them in exact reverse generated order', async () => { + vi.useFakeTimers(); + const integrationIds = generatedIntegrationIds(); + const events: string[] = []; + const registrations = integrationIds.map((id) => { + const factory = REGISTRATION_FACTORIES.get(id); + if (!factory) throw new Error(`Missing real registration factory for ${id}`); + return tracedRegistration(factory(TEST_RELEASE_ID), events); + }); + const activeObservers = new Set(); + const activeMutationObservers = new Set(); + const NativeMutationObserver = window.MutationObserver; + class TrackedMutationObserver extends NativeMutationObserver { + public constructor(callback: MutationCallback) { + super(callback); + activeMutationObservers.add(this); + } + + public override disconnect(): void { + activeMutationObservers.delete(this); + super.disconnect(); + } + } + vi.stubGlobal('MutationObserver', TrackedMutationObserver); + let activeCaptureListeners = 0; + const googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + activeObservers.add(observer); + return (): void => { + activeObservers.delete(observer); + }; + }, + }); + const target: Record = {}; + const appendChildBefore = Element.prototype.appendChild; + const insertBeforeBefore = Element.prototype.insertBefore; + const fetchBefore = Object.getOwnPropertyDescriptor(window, 'fetch'); + const sendBeaconBefore = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const didomiBefore = Object.getOwnPropertyDescriptor(window, 'didomiConfig'); + const testlightBefore = Object.getOwnPropertyDescriptor(window, 'testlight'); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: TEST_RELEASE_ID, + manifest: { + version: 1, + releaseId: TEST_RELEASE_ID, + integrations: integrationIds.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: integrationIds, + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + getBindings: (id) => + Object.freeze({ config: integrationConfig(id), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag, + messaging: Object.freeze({ + ...createNoopMessagingAdapter(), + installCaptureListener: () => { + activeCaptureListeners += 1; + let active = true; + return (): void => { + if (!active) return; + active = false; + activeCaptureListeners -= 1; + }; + }, + }), + prebid: createNoopPrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect(composition.runtime.start()).toBe(false); + for (const registration of registrations) { + expect(registration.release).toBe(TEST_RELEASE_ID); + expect(composition.runtime.registerIntegration(registration)).toBe(true); + events.push(`register:${registration.id}`); + } + + const installed = await composition.runtime.install(); + + if (installed.state === 'fallback') { + throw new Error(`${installed.reason}: ${events.join(',')}`); + } + expect(installed).toEqual({ + state: 'kernel', + runtimeFailures: [], + dispose: expect.any(Function), + }); + expect(composition.runtime.state).toBe('kernel'); + expect(target['releaseId']).toBe(TEST_RELEASE_ID); + expect(events.filter((event) => event.startsWith('register:'))).toEqual( + integrationIds.map((id) => `register:${id}`) + ); + expect(events.filter((event) => event.startsWith('prepare:'))).toEqual( + integrationIds.map((id) => `prepare:${id}`) + ); + expect(events.filter((event) => event.startsWith('activate:'))).toEqual( + integrationIds.map((id) => `activate:${id}`) + ); + expect(composition.runtimeSessionForTest()?.interfaces).toMatchObject( + Object.fromEntries(integrationIds.map((id) => [id, expect.any(Object)])) + ); + expect(composition.auctionContextRegistryForTest()?.snapshotInventoryForTest()).toEqual({ + disposed: false, + registrations: ['permutive'], + }); + expect(activeObservers.size).toBe(1); + expect(activeMutationObservers.size).toBeGreaterThan(0); + expect(activeCaptureListeners).toBe(1); + + window.dispatchEvent(new Event('resize')); + composition.runtime.dispose(); + composition.runtime.dispose(); + await Promise.resolve(); + + expect(events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...integrationIds].reverse().map((id) => `dispose:${id}`) + ); + expect(activeObservers.size).toBe(0); + expect(activeMutationObservers.size).toBe(0); + expect(activeCaptureListeners).toBe(0); + expect(composition.auctionContextRegistryForTest()).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + expect(Element.prototype.appendChild).toBe(appendChildBefore); + expect(Element.prototype.insertBefore).toBe(insertBeforeBefore); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchBefore); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconBefore); + expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); + expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); + }); +}); diff --git a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts index f36a1a500..838c51b77 100644 --- a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts +++ b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts @@ -147,6 +147,20 @@ describe('Beacon Guard', () => { }); }); + it('restores the exact publisher-owned descriptors on reset', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + + guard.install(); + guard.reset(); + + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + sendBeaconDescriptor + ); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + describe('multiple guards', () => { it('should allow independent guards to coexist', () => { const config2: BeaconGuardConfig = { From 14ae7ae0b68407cdac79e43cc68572449608f84a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:10:19 -0700 Subject: [PATCH 400/494] Wire GPT facts into render diagnostics --- .../lib/src/composition/browser.ts | 43 ++++++- .../lib/test/composition/browser.test.ts | 111 +++++++++++++++++- 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 2698c870c..6aa3df2bd 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -30,6 +30,7 @@ import type { import { createRenderTrace, isEffectivelyVisible, + type RenderTraceGptFactV1, type RenderTraceRuntimeOwner, } from '../core/trace'; import { @@ -213,7 +214,7 @@ export interface BrowserCoreActivations { export interface TestBrowserRuntimeCompositionOptions extends BrowserCompositionOptions { readonly auctionFetcherForTest?: AuctionBatchFetcher; - readonly coreActivations: BrowserCoreActivations; + readonly coreActivations?: BrowserCoreActivations; readonly creativeActivationForTest?: (config: Readonly) => () => void; readonly creativeStartupForTest?: (config: Readonly) => void; readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; @@ -413,12 +414,12 @@ export function createNoopBrowserComposition(): BrowserComposition { } /** - * Construct the single runtime only for coordinated-cutover tests. + * Construct the sole browser runtime composition without claiming a global. * - * The shipped core remains on its existing bootstrap until Task 19; keeping this - * explicit prevents an import of the composition module from claiming globals. + * The core entry point owns the one production claim; tests may construct the + * same composition against explicit targets and adapters. */ -export function createTestBrowserRuntimeComposition( +export function createBrowserRuntimeComposition( runtimeOptions: RuntimeOptions, compositionOptions: TestBrowserRuntimeCompositionOptions ): BrowserRuntimeComposition { @@ -442,6 +443,33 @@ export function createTestBrowserRuntimeComposition( observation['kind'] === 'impressionViewable' || observation['kind'] === 'slotVisibilityChanged' ) { + try { + renderTrace?.observeGptFact( + observation as unknown as Readonly, + (elementId) => { + if (typeof elementId !== 'string' || elementId === '') return undefined; + const slots = browserServices?.slots; + const slot = + slots?.resolveDomAlias(elementId) ?? slots?.resolveRegisteredSlot(elementId); + if (!slot) return undefined; + let element: HTMLElement | undefined; + if (typeof document !== 'undefined') { + const matches = [...document.querySelectorAll('[id]')].filter( + (candidate) => candidate.id === elementId + ); + if (matches.length === 1) element = matches[0]; + } + return Object.freeze({ + slotId: slot.registeredSlotId, + ...(element === undefined + ? {} + : { elementId: element.id, visible: isEffectivelyVisible(element) }), + }); + } + ); + } catch { + // Render tracing never affects an already-committed adapter observation. + } try { gptDiagnosticsFacts?.publish(observation as unknown as Readonly); } catch { @@ -1270,7 +1298,7 @@ export function createTestBrowserRuntimeComposition( }); browserServices.slots.activate(); browserServices.slots.start(); - compositionOptions.coreActivations.correctnessGptListeners( + compositionOptions.coreActivations?.correctnessGptListeners( context, composition.adapters, browserServices @@ -1332,3 +1360,6 @@ export function createTestBrowserRuntimeComposition( }, }); } + +/** Temporary test import alias; production has only one composition implementation. */ +export const createTestBrowserRuntimeComposition = createBrowserRuntimeComposition; diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 3352eae5b..4ec11e107 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -5,6 +5,7 @@ import { createNoopGoogletagAdapter, type GoogletagAdapter, type GoogletagBindingStatus, + type GoogletagDiagnosticsFact, type GoogletagDiagnosticsObserver, type GoogletagFacade, type GoogletagPublisherCallObserver, @@ -28,6 +29,7 @@ import { } from '../../src/adapters/prebid'; import { createBrowserComposition, + createBrowserRuntimeComposition, createNoopBrowserComposition, createTestBrowserRuntimeComposition, } from '../../src/composition/browser'; @@ -78,6 +80,7 @@ function synchronousGptAdapter() { const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); const refresh = vi.fn(); + const diagnosticsSlots = new WeakMap(); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; let publisherObserver: GoogletagPublisherCallObserver | undefined; const facade: GoogletagFacade = Object.freeze({ @@ -146,11 +149,32 @@ function synchronousGptAdapter() { for (const listener of listeners.get(eventType) ?? []) { listener(event); if (typeof event !== 'object' || event === null || !('slot' in event)) continue; + const physicalSlot = event.slot; + if (typeof physicalSlot !== 'object' || physicalSlot === null) continue; + let safeSlot = diagnosticsSlots.get(physicalSlot); + if (!safeSlot) { + const elementId = + 'getSlotElementId' in physicalSlot && + typeof physicalSlot.getSlotElementId === 'function' + ? physicalSlot.getSlotElementId() + : undefined; + const adUnitPath = + 'getAdUnitPath' in physicalSlot && typeof physicalSlot.getAdUnitPath === 'function' + ? physicalSlot.getAdUnitPath() + : undefined; + safeSlot = Object.freeze({ + token: Object.freeze(Object.create(null) as object), + ...(typeof elementId === 'string' ? { elementId } : {}), + ...(typeof adUnitPath === 'string' ? { adUnitPath } : {}), + }); + diagnosticsSlots.set(physicalSlot, safeSlot); + } diagnosticsObserver?.( Object.freeze({ ...event, kind: eventType, - slot: event.slot, + observedAtMs: 1, + slot: safeSlot, }) as Parameters[0] ); } @@ -1024,7 +1048,11 @@ describe('browser composition', () => { expect(observations).toContainEqual( expect.objectContaining({ kind: 'slotRenderEnded', - slot: observedSlot, + slot: expect.objectContaining({ + elementId: 'bus-slot', + adUnitPath: '/example/bus-slot', + token: expect.any(Object), + }), isEmpty: false, }) ) @@ -1034,6 +1062,85 @@ describe('browser composition', () => { } }); + it('routes safe GPT facts into the same-impression render trace state machine', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const composition = createBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const addAdUnits = target['addAdUnits'] as (unit: unknown) => unknown; + addAdUnits({ + code: 'gpt-trace-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + const physicalSlot = Object.freeze({ + getSlotElementId: () => 'gpt-trace-slot', + getAdUnitPath: () => '/example/gpt-trace-slot', + }); + + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { slot: physicalSlot, isEmpty: false }); + gpt.emit('impressionViewable', { slot: physicalSlot }); + + const diagnostics = target['diagnostics'] as { + renderTrace: { + current(): Readonly>>>; + history(): readonly Readonly>[]; + }; + }; + expect(diagnostics.renderTrace.current()['gpt-trace-slot']).toEqual( + expect.objectContaining({ + path: 'gam-refresh', + rendered: true, + gamEmpty: false, + injected: false, + visible: true, + servedFrom: 'gam', + }) + ); + expect(diagnostics.renderTrace.history()).toHaveLength(1); + } finally { + composition.runtime.dispose(); + } + }); + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; From 747a6304f2fdb44c56b37bcfd2c3b1b7e2e469d4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:15:00 -0700 Subject: [PATCH 401/494] Test GPT fact identity and timing --- .../lib/test/adapters/googletag.test.ts | 44 ++++++++++++++++++- .../gpt_diagnostics/index.test.ts | 19 ++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 1aadcdae0..4b57e7c9d 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createBrowserGoogletagAdapter } from '../../src/adapters/googletag'; +import { + createBrowserGoogletagAdapter, + type GoogletagDiagnosticsFact, +} from '../../src/adapters/googletag'; type Command = () => void; @@ -1183,6 +1186,45 @@ describe('browser googletag adapter readiness', () => { expect(ready.pubads.addEventListener).not.toHaveBeenCalled(); }); + it('keeps one non-capability token per physical Slot and never freezes publisher authority', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ + googletag: ready.googletag, + performance: { now: () => 7 }, + }); + const facts: GoogletagDiagnosticsFact[] = []; + adapter.observeDiagnostics?.((fact) => facts.push(fact)); + await adapter.run((gpt) => gpt.subscribe('slotRequested', () => undefined)).result; + const first = { + getSlotElementId: () => 'same-id', + getAdUnitPath: () => '/example/first', + setTargeting: vi.fn(), + }; + const replacement = { + getSlotElementId: () => 'same-id', + getAdUnitPath: () => '/example/replacement', + setTargeting: vi.fn(), + }; + const emit = (slot: object): void => { + for (const listener of ready.listeners.get('slotRequested') ?? []) listener({ slot }); + }; + + emit(first); + emit(first); + emit(replacement); + + expect(facts).toHaveLength(3); + expect(facts[0]?.slot.token).toBe(facts[1]?.slot.token); + expect(facts[2]?.slot.token).not.toBe(facts[0]?.slot.token); + expect(Object.isFrozen(first)).toBe(false); + expect(Object.isFrozen(replacement)).toBe(false); + expect(Reflect.ownKeys(facts[0]?.slot ?? {}).sort()).toEqual([ + 'adUnitPath', + 'elementId', + 'token', + ]); + }); + it('rolls back an exact GPT listener when installation replaces the binding', async () => { const first = createReadyGoogletag(); const replacement = createReadyGoogletag(); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts index b0ed27ca8..4f8c9b10e 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts @@ -101,6 +101,25 @@ describe('GPT diagnostics runtime', () => { release(); }); + it('retains adapter callback timing across delayed fact-buffer replay', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const observedSlot = slot('timed-slot'); + buffer.publish(fact('slotRequested', observedSlot, { observedAtMs: 10 })); + buffer.publish(fact('slotResponseReceived', observedSlot, { observedAtMs: 25 })); + const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); + + const release = runtime.activate(); + buffer.publish(fact('slotRenderEnded', observedSlot, { observedAtMs: 30, isEmpty: false })); + + expect(runtime.currentApi()?.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedAtMs: 10, + responseAtMs: 25, + renderAtMs: 30, + durations: { requestToResponseMs: 15, responseToRenderMs: 5, requestToRenderMs: 20 }, + }); + release(); + }); + it('releases its consumer so replacement activation receives intervening buffered facts', () => { const buffer = createGptDiagnosticsFactBuffer(); const runtime = createGptDiagnosticsRuntime(buffer, { window, document }); From a1692a0a7cf24aabc2ff21d238e023fc83711b96 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:17:09 -0700 Subject: [PATCH 402/494] Format GPT diagnostics resilience changes --- .../lib/src/adapters/googletag.ts | 9 +++++-- .../src/integrations/gpt_diagnostics/store.ts | 14 +++++----- .../gpt_diagnostics/bootstrap.test.ts | 26 ++++++++++++------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index fb49c48a3..a540046b8 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -893,7 +893,9 @@ export function createBrowserGoogletagAdapter( const physicalSlot = slot as object; let safeSlot = weakMapValue(diagnosticsSlots, physicalSlot); if (!safeSlot) { - const optionalStringCall = (key: 'getSlotElementId' | 'getAdUnitPath'): string | undefined => { + const optionalStringCall = ( + key: 'getSlotElementId' | 'getAdUnitPath' + ): string | undefined => { const method = safeMember(physicalSlot, key); if (typeof method !== 'function') return undefined; try { @@ -967,7 +969,10 @@ export function createBrowserGoogletagAdapter( let observedAtMs = 0; try { const performance = safeMember(target, 'performance'); - if ((typeof performance === 'object' && performance !== null) || typeof performance === 'function') { + if ( + (typeof performance === 'object' && performance !== null) || + typeof performance === 'function' + ) { const now = safeMember(performance as object, 'now'); if (typeof now === 'function') { const value = Reflect.apply(now, performance, []); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index c0cff3276..9183b6e09 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -431,15 +431,17 @@ export class GptDiagnosticsStore { record.slotElementId ??= (typeof slot.elementId === 'string' && slot.elementId.length > 0 ? slot.elementId - : undefined) ?? optionalNonEmptyString( - typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined - ); + : undefined) ?? + optionalNonEmptyString( + typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined + ); record.adUnitPath ??= (typeof slot.adUnitPath === 'string' && slot.adUnitPath.length > 0 ? slot.adUnitPath - : undefined) ?? optionalNonEmptyString( - typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined - ); + : undefined) ?? + optionalNonEmptyString( + typeof slot.getAdUnitPath === 'function' ? slot.getAdUnitPath.bind(slot) : undefined + ); } private markRecentlyActive(runtimeSlotNumber: number): void { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts index 741b70459..db827cbb5 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts @@ -34,7 +34,11 @@ describe('GPT diagnostics activation ownership', () => { throw new Error('fictional history failure'); }); expect(() => - Function('location', 'history', bootstrapSource)( + Function( + 'location', + 'history', + bootstrapSource + )( Object.freeze({ href: 'https://publisher.example/?ts_console=false#kept' }), Object.freeze({ replaceState, state: null }) ) @@ -42,7 +46,11 @@ describe('GPT diagnostics activation ownership', () => { expect(replaceState).toHaveBeenCalledOnce(); replaceState.mockClear(); - Function('location', 'history', bootstrapSource)( + Function( + 'location', + 'history', + bootstrapSource + )( Object.freeze({ href: 'https://publisher.example/?contest_console=1#kept' }), Object.freeze({ replaceState, state: null }) ); @@ -52,17 +60,15 @@ describe('GPT diagnostics activation ownership', () => { it.each([ ['https://publisher.example/a?ts_console=1', 'https://publisher.example/a'], ['https://publisher.example/a?ts_console=1&', 'https://publisher.example/a'], - [ - 'https://publisher.example/a?&ts_console=1&keep=%2F', - 'https://publisher.example/a?&keep=%2F', - ], + ['https://publisher.example/a?&ts_console=1&keep=%2F', 'https://publisher.example/a?&keep=%2F'], ])('matches the server sanitizer for empty raw query segments', (href, expected) => { const replaceState = vi.fn(); - Function('location', 'history', bootstrapSource)( - Object.freeze({ href }), - Object.freeze({ replaceState, state: null }) - ); + Function( + 'location', + 'history', + bootstrapSource + )(Object.freeze({ href }), Object.freeze({ replaceState, state: null })); expect(replaceState).toHaveBeenCalledExactlyOnceWith(null, '', expected); }); From b79b4fc511ce1cdd222e956a43c3d5357ca8f1a2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:20:58 -0700 Subject: [PATCH 403/494] Preserve publisher beacon replacements --- .../lib/src/shared/beacon_guard.ts | 71 +++++-- .../lib/test/shared/beacon_guard.test.ts | 173 +++++++++++++++++- 2 files changed, 225 insertions(+), 19 deletions(-) diff --git a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts index d7d4273e7..a5a99552f 100644 --- a/crates/trusted-server-js/lib/src/shared/beacon_guard.ts +++ b/crates/trusted-server-js/lib/src/shared/beacon_guard.ts @@ -50,6 +50,40 @@ function extractUrl(input: RequestInfo | URL): string | null { return null; } +function sameDescriptor( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined +): boolean { + if (!left || !right) return left === right; + if (left.configurable !== right.configurable || left.enumerable !== right.enumerable) { + return false; + } + if ('value' in left || 'value' in right) { + return ( + 'value' in left && + 'value' in right && + left.value === right.value && + left.writable === right.writable + ); + } + return left.get === right.get && left.set === right.set; +} + +function restoreOwnedDescriptor( + target: object, + property: PropertyKey, + installed: PropertyDescriptor | undefined, + original: PropertyDescriptor | undefined +): void { + try { + if (!sameDescriptor(Object.getOwnPropertyDescriptor(target, property), installed)) return; + if (original) Object.defineProperty(target, property, original); + else Reflect.deleteProperty(target, property); + } catch { + // Publisher replacement or a hostile descriptor cannot block independent cleanup. + } +} + /** * Create an independent beacon guard for a specific integration. */ @@ -57,8 +91,10 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { let installed = false; let originalSendBeacon: typeof navigator.sendBeacon | undefined; let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let installedSendBeaconDescriptor: PropertyDescriptor | undefined; let originalFetch: typeof window.fetch | undefined; let originalFetchDescriptor: PropertyDescriptor | undefined; + let installedFetchDescriptor: PropertyDescriptor | undefined; let sendBeaconPatched = false; let fetchPatched = false; const prefix = `${config.name} beacon guard`; @@ -82,7 +118,7 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); sendBeaconPatched = true; - navigator.sendBeacon = function (url: string, data?: BodyInit | null): boolean { + const wrapper = function (url: string, data?: BodyInit | null): boolean { const sendBeacon = originalSendBeacon; if (!sendBeacon) return false; if (config.isTargetUrl(url)) { @@ -92,6 +128,12 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { } return Reflect.apply(sendBeacon, navigator, [url, data]); }; + navigator.sendBeacon = wrapper; + installedSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + sendBeaconPatched = sameDescriptor(installedSendBeaconDescriptor, { + ...installedSendBeaconDescriptor, + value: wrapper, + }); } // --- Patch window.fetch --- @@ -100,7 +142,7 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); fetchPatched = true; - window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise { + const wrapper = function (input: RequestInfo | URL, init?: RequestInit): Promise { const fetch = originalFetch; if (!fetch) return Promise.reject(new TypeError('fetch is unavailable')); const url = extractUrl(input); @@ -119,6 +161,12 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { return Reflect.apply(fetch, window, [input, init]); }; + window.fetch = wrapper; + installedFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchPatched = sameDescriptor(installedFetchDescriptor, { + ...installedFetchDescriptor, + value: wrapper, + }); } installed = true; @@ -131,23 +179,22 @@ export function createBeaconGuard(config: BeaconGuardConfig): BeaconGuard { function reset(): void { if (sendBeaconPatched && typeof navigator !== 'undefined') { - if (originalSendBeaconDescriptor) { - Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); - } else { - Reflect.deleteProperty(navigator, 'sendBeacon'); - } + restoreOwnedDescriptor( + navigator, + 'sendBeacon', + installedSendBeaconDescriptor, + originalSendBeaconDescriptor + ); } if (fetchPatched && typeof window !== 'undefined') { - if (originalFetchDescriptor) { - Object.defineProperty(window, 'fetch', originalFetchDescriptor); - } else { - Reflect.deleteProperty(window, 'fetch'); - } + restoreOwnedDescriptor(window, 'fetch', installedFetchDescriptor, originalFetchDescriptor); } originalSendBeacon = undefined; originalSendBeaconDescriptor = undefined; + installedSendBeaconDescriptor = undefined; originalFetch = undefined; originalFetchDescriptor = undefined; + installedFetchDescriptor = undefined; sendBeaconPatched = false; fetchPatched = false; installed = false; diff --git a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts index 838c51b77..dd3995e39 100644 --- a/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts +++ b/crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts @@ -4,16 +4,16 @@ import { createBeaconGuard } from '../../src/shared/beacon_guard'; import type { BeaconGuardConfig } from '../../src/shared/beacon_guard'; describe('Beacon Guard', () => { - let originalSendBeacon: typeof navigator.sendBeacon; - let originalFetch: typeof window.fetch; + let originalSendBeaconDescriptor: PropertyDescriptor | undefined; + let originalFetchDescriptor: PropertyDescriptor | undefined; let sendBeaconSpy: ReturnType; let fetchSpy: ReturnType; let config: BeaconGuardConfig; beforeEach(() => { // Save originals - originalSendBeacon = navigator.sendBeacon; - originalFetch = window.fetch; + originalSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + originalFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); // Create spies that simulate real sendBeacon/fetch behaviour sendBeaconSpy = vi.fn(() => true); @@ -31,8 +31,16 @@ describe('Beacon Guard', () => { }); afterEach(() => { - navigator.sendBeacon = originalSendBeacon; - window.fetch = originalFetch; + if (originalSendBeaconDescriptor) { + Object.defineProperty(navigator, 'sendBeacon', originalSendBeaconDescriptor); + } else { + Reflect.deleteProperty(navigator, 'sendBeacon'); + } + if (originalFetchDescriptor) { + Object.defineProperty(window, 'fetch', originalFetchDescriptor); + } else { + Reflect.deleteProperty(window, 'fetch'); + } }); describe('createBeaconGuard', () => { @@ -155,9 +163,160 @@ describe('Beacon Guard', () => { guard.install(); guard.reset(); - expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + + it.each(['sendBeacon', 'fetch'] as const)( + 'leaves a publisher %s replacement intact while releasing the other wrapper', + (replaced) => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + guard.install(); + const replacementSendBeacon = vi.fn(() => false) as typeof navigator.sendBeacon; + const replacementFetch = vi.fn(() => Promise.resolve(new Response())) as typeof window.fetch; + if (replaced === 'sendBeacon') navigator.sendBeacon = replacementSendBeacon; + else window.fetch = replacementFetch; + + guard.reset(); + + if (replaced === 'sendBeacon') { + expect(navigator.sendBeacon).toBe(replacementSendBeacon); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + } else { + expect(window.fetch).toBe(replacementFetch); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + sendBeaconDescriptor + ); + } + } + ); + + it('leaves descriptor-attribute changes to the installed wrappers intact', () => { + const guard = createBeaconGuard(config); + guard.install(); + const installedSendBeacon = navigator.sendBeacon; + const installedFetch = window.fetch; + const sendBeaconReplacement = { + configurable: true, + enumerable: false, + value: installedSendBeacon, + writable: true, + } satisfies PropertyDescriptor; + const fetchReplacement = { + configurable: true, + enumerable: false, + value: installedFetch, + writable: true, + } satisfies PropertyDescriptor; + Object.defineProperty(navigator, 'sendBeacon', sendBeaconReplacement); + Object.defineProperty(window, 'fetch', fetchReplacement); + + guard.reset(); + + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconReplacement); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchReplacement); + }); + + it('does not invoke or replace hostile publisher accessors during reset', () => { + const guard = createBeaconGuard(config); + guard.install(); + const sendBeaconGetter = vi.fn(() => { + throw new Error('sendBeacon getter must remain inert'); + }); + const fetchGetter = vi.fn(() => { + throw new Error('fetch getter must remain inert'); + }); + const sendBeaconReplacement = { + configurable: true, + enumerable: true, + get: sendBeaconGetter, + } satisfies PropertyDescriptor; + const fetchReplacement = { + configurable: true, + enumerable: true, + get: fetchGetter, + } satisfies PropertyDescriptor; + Object.defineProperty(navigator, 'sendBeacon', sendBeaconReplacement); + Object.defineProperty(window, 'fetch', fetchReplacement); + + expect(() => guard.reset()).not.toThrow(); + + expect(sendBeaconGetter).not.toHaveBeenCalled(); + expect(fetchGetter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconReplacement); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchReplacement); + }); + + it('isolates hostile descriptor inspection and still releases the other wrapper', () => { + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const guard = createBeaconGuard(config); + guard.install(); + const installedSendBeacon = navigator.sendBeacon; + const nativeDescriptor = Object.getOwnPropertyDescriptor; + const descriptor = vi + .spyOn(Object, 'getOwnPropertyDescriptor') + .mockImplementation((target, property) => { + if (target === navigator && property === 'sendBeacon') { + throw new Error('publisher descriptor inspection failed'); + } + return nativeDescriptor(target, property); + }); + + expect(() => guard.reset()).not.toThrow(); + descriptor.mockRestore(); + + expect(navigator.sendBeacon).toBe(installedSendBeacon); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); + }); + + it('releases an installed wrapper after a later patch assignment fails', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + if (!fetchDescriptor || !('value' in fetchDescriptor)) { + throw new Error('test requires an own fetch data descriptor'); + } + const nonWritableFetchDescriptor = { + ...fetchDescriptor, + writable: false, + } satisfies PropertyDescriptor; + Object.defineProperty(window, 'fetch', nonWritableFetchDescriptor); + const guard = createBeaconGuard(config); + + expect(() => guard.install()).toThrow(TypeError); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).not.toEqual( sendBeaconDescriptor ); + + expect(() => guard.reset()).not.toThrow(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(nonWritableFetchDescriptor); + }); + + it('restores stacked guards in reverse order and remains idempotent', () => { + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const first = createBeaconGuard(config); + const second = createBeaconGuard({ + ...config, + name: 'Second', + }); + first.install(); + const firstSendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const firstFetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + second.install(); + + second.reset(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual( + firstSendBeaconDescriptor + ); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(firstFetchDescriptor); + + first.reset(); + first.reset(); + second.reset(); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconDescriptor); expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchDescriptor); }); From 86f8aa7b6ce86ffbcc3e5acbf34558a2a4a4dd58 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:33:28 -0700 Subject: [PATCH 404/494] Reconcile GPT-first render trace terminals --- .../trusted-server-js/lib/src/core/trace.ts | 19 +++ .../lib/test/composition/browser.test.ts | 153 ++++++++++++++++++ .../lib/test/core/trace_runtime.test.ts | 68 ++++++++ 3 files changed, 240 insertions(+) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index 00daa12c2..a59f9709f 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -964,6 +964,7 @@ export function createRenderTraceDiagnostics( object, { readonly baselineSequence: number | undefined; + reconciled?: boolean; renderEnded?: boolean; sequence?: number; readonly slotId: string; @@ -1065,6 +1066,24 @@ export function createRenderTraceDiagnostics( history.some((candidate) => candidate.seq === record.seq); const record = (input: RenderTraceInputV1): Readonly => { + if (!disposed && input.path !== 'gam-refresh') { + for (const impression of gptImpressions.values()) { + if ( + impression.slotId !== input.slotId || + impression.renderEnded !== true || + impression.reconciled === true || + impression.sequence === undefined || + current.get(input.slotId)?.seq !== impression.sequence + ) { + continue; + } + const reconciled = enrich(impression.sequence, input); + if (reconciled) { + impression.reconciled = true; + return reconciled; + } + } + } const previous = current.get(input.slotId); let at: number; try { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index 4ec11e107..f6297b68e 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1141,6 +1141,159 @@ describe('browser composition', () => { } }); + it('reconciles a trusted terminal that arrives after the GPT render fact', async () => { + const releaseId = 'a'.repeat(64); + const target: Record = {}; + const gpt = synchronousGptAdapter(); + const renderSource = Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
reverse-order winner
', + width: 300, + height: 250, + }); + const auctionFetcher = vi.fn(async () => ({ + ok: true, + json: async () => ({ + id: 'reverse-auction', + cur: 'USD', + seatbid: [ + { + seat: 'fictional', + bid: [ + { + id: 'r1_AAAAAAAAAAAAAAAAAAAAAA', + impid: 'reverse-order-slot', + price: 1, + adm: renderSource.adm, + w: renderSource.width, + h: renderSource.height, + ext: { + trusted_server: { + candidate_id: 'AAAAAAAAAAAA', + slot_id: 'reverse-order-slot', + render_source: renderSource, + }, + }, + }, + ], + }, + ], + ext: { + trusted_server: { + slot_results: { + version: 1, + auctionId: 'reverse-auction', + results: [ + { + slot: 'reverse-order-slot', + outcome: 'winner', + candidateId: 'AAAAAAAAAAAA', + }, + ], + }, + }, + }, + }), + })); + const composition = createBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: 'gpt_diagnostics', required: true }], + }, + knownIntegrationIds: Object.freeze(['gpt_diagnostics']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + auctionFetcherForTest: auctionFetcher, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration( + createGptDiagnosticsIntegrationRegistration(releaseId) + ) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const api = target as { + addAdUnits(value: unknown): unknown; + requestAds(options: unknown): Promise; + diagnostics: { + renderTrace: { + current(): Readonly>>>; + history(): readonly Readonly>[]; + }; + }; + }; + api.addAdUnits({ + code: 'reverse-order-slot', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + document.body.innerHTML = '
'; + const physicalSlot = Object.freeze({ + getSlotElementId: () => 'reverse-order-slot', + getAdUnitPath: () => '/example/reverse-order-slot', + }); + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { slot: physicalSlot, isEmpty: false }); + const provisional = api.diagnostics.renderTrace.current()['reverse-order-slot']; + + const request = api.requestAds({ slots: ['reverse-order-slot'] }); + await vi.waitFor(() => + expect(document.querySelector('#reverse-order-slot iframe')).not.toBeNull() + ); + document + .querySelector('#reverse-order-slot iframe') + ?.dispatchEvent(new Event('load')); + await expect(request).resolves.toEqual({ + slots: [{ slot: 'reverse-order-slot', path: 'primary', outcome: 'accepted' }], + }); + + expect(api.diagnostics.renderTrace.current()['reverse-order-slot']).toEqual( + expect.objectContaining({ + seq: provisional?.['seq'], + count: provisional?.['count'], + at: provisional?.['at'], + path: 'auction', + rendered: true, + injected: true, + gamEmpty: false, + servedFrom: 'inline', + }) + ); + expect(api.diagnostics.renderTrace.history()).toHaveLength(1); + gpt.emit('slotVisibilityChanged', { slot: physicalSlot, inViewPercentage: 0 }); + expect(api.diagnostics.renderTrace.current()['reverse-order-slot']).toEqual( + expect.objectContaining({ seq: provisional?.['seq'], path: 'auction', visible: false }) + ); + expect(api.diagnostics.renderTrace.history()).toHaveLength(1); + } finally { + composition.runtime.dispose(); + document.body.innerHTML = ''; + } + }); + it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index abded6af1..572aaa214 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -226,6 +226,74 @@ describe('render trace diagnostics runtime', () => { expect(owner.diagnostics.history()).toHaveLength(1); }); + it('reconciles a later trusted terminal into the GPT-first impression', () => { + const { owner, tasks, drain } = harness(); + const listener = vi.fn(); + const token = Object.freeze(Object.create(null) as object); + const slot = Object.freeze({ token, elementId: 'reverse-slot' }); + const resolve = () => + Object.freeze({ slotId: 'reverse-slot', elementId: 'reverse-slot', visible: true }); + owner.diagnostics.subscribe(listener); + + owner.observeGptFact(Object.freeze({ kind: 'slotRequested', observedAtMs: 1, slot }), resolve); + owner.observeGptFact( + Object.freeze({ kind: 'slotRenderEnded', observedAtMs: 2, slot, isEmpty: false }), + resolve + ); + const provisional = owner.diagnostics.current()['reverse-slot']; + expect(provisional).toEqual( + expect.objectContaining({ path: 'gam-refresh', rendered: true, injected: false }) + ); + + const terminal = owner.record({ + slotId: 'reverse-slot', + path: 'ssat', + rendered: true, + injected: true, + bidder: 'trusted-bidder', + bidId: 'trusted-bid', + creativeId: 'trusted-creative', + servedFrom: 'pbs-cache', + }); + + expect(terminal).toEqual( + expect.objectContaining({ + seq: provisional?.seq, + count: provisional?.count, + at: provisional?.at, + path: 'ssat', + bidder: 'trusted-bidder', + bidId: 'trusted-bid', + creativeId: 'trusted-creative', + servedFrom: 'pbs-cache', + rendered: true, + injected: true, + gamEmpty: false, + }) + ); + expect(owner.diagnostics.history()).toEqual([terminal]); + expect(tasks).toHaveLength(1); + drain(); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ seq: terminal.seq, path: 'ssat' }) + ); + + owner.observeGptFact( + Object.freeze({ + kind: 'slotVisibilityChanged', + observedAtMs: 3, + slot, + inViewPercentage: 0, + }), + resolve + ); + expect(owner.diagnostics.current()['reverse-slot']).toEqual( + expect.objectContaining({ seq: terminal.seq, path: 'ssat', visible: false }) + ); + expect(owner.diagnostics.history()).toHaveLength(1); + }); + it('enriches only the same GPT impression without weakening TS placement truth', () => { const { owner } = harness(); const token = Object.freeze(Object.create(null) as object); From 1b1255679e00a25900d8de0d7b66bff6fc9e34bf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:40:25 -0700 Subject: [PATCH 405/494] Isolate Lockr cleanup failures --- .../lib/src/integrations/lockr/module.ts | 15 ++++++-- .../test/integrations/lockr/module.test.ts | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/module.ts b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts index 0b91d31ed..77512c7a2 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/module.ts @@ -24,6 +24,14 @@ export interface LockrRuntimeDependencies { readonly timedOut: () => void; } +function bestEffort(action: () => void): void { + try { + action(); + } catch { + // Cleanup is isolated so one hostile publisher hook cannot retain another resource. + } +} + /** Own the Lockr guard, bounded SDK readiness timer, and installed API host. */ export function createLockrRuntime( dependencies: LockrRuntimeDependencies = { @@ -78,11 +86,12 @@ export function createLockrRuntime( active = false; started = false; if (timer !== undefined) { - dependencies.clearTimeout(timer); + const ownedTimer = timer; timer = undefined; + bestEffort(() => dependencies.clearTimeout(ownedTimer)); } - resetSdk(); - dependencies.resetGuard(); + bestEffort(resetSdk); + bestEffort(dependencies.resetGuard); }; }, start: (_config: unknown): void => { diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts index d44677b10..2581fbd7c 100644 --- a/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/lockr/module.test.ts @@ -84,4 +84,39 @@ describe('transactional Lockr integration module', () => { expect(sdk.host).toBe('https://identity.loc.kr'); expect(vi.getTimerCount()).toBe(0); }); + + it('isolates a hostile timer release from SDK and guard cleanup', () => { + const sdk = { host: 'https://identity.loc.kr' }; + let sdkAvailable = false; + const clearTimeout = vi.fn(() => { + throw new Error('publisher clearTimeout failed'); + }); + const resetGuard = vi.fn(() => { + throw new Error('publisher guard reset failed'); + }); + const runtime = createLockrRuntime({ + clearTimeout, + getSdk: () => (sdkAvailable ? sdk : undefined), + installGuard: vi.fn(), + location: { host: 'news.example', protocol: 'https:' }, + resetGuard, + setTimeout: (callback) => { + sdkAvailable = true; + callback(); + return 17; + }, + started: vi.fn(), + timedOut: vi.fn(), + }); + const release = runtime.activate(undefined); + runtime.start(undefined); + + expect(sdk.host).toBe('https://news.example/integrations/lockr/api'); + expect(() => release()).not.toThrow(); + expect(() => release()).not.toThrow(); + + expect(clearTimeout).toHaveBeenCalledOnce(); + expect(sdk.host).toBe('https://identity.loc.kr'); + expect(resetGuard).toHaveBeenCalledOnce(); + }); }); From ccdea4599d0e0b8d337ec0d7c69a73c7f7a4fb50 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:54:25 -0700 Subject: [PATCH 406/494] Keep render trace counts aligned with current slots --- .../trusted-server-js/lib/src/core/trace.ts | 25 +++++++----- .../lib/test/core/trace_runtime.test.ts | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/lib/src/core/trace.ts b/crates/trusted-server-js/lib/src/core/trace.ts index a59f9709f..89eb4a02d 100644 --- a/crates/trusted-server-js/lib/src/core/trace.ts +++ b/crates/trusted-server-js/lib/src/core/trace.ts @@ -1085,6 +1085,10 @@ export function createRenderTraceDiagnostics( } } const previous = current.get(input.slotId); + const evictedCurrentSlot = + !previous && current.size >= MAX_RENDER_TRACE_SLOTS + ? (current.keys().next().value as string | undefined) + : undefined; let at: number; try { at = (options.now ?? Date.now)(); @@ -1093,10 +1097,16 @@ export function createRenderTraceDiagnostics( } const previousCount = counts.get(input.slotId) ?? 0; if (!counts.has(input.slotId) && counts.size >= MAX_RENDER_TRACE_SLOTS) { - const oldestCount = counts.keys().next().value as string | undefined; - if (oldestCount !== undefined) counts.delete(oldestCount); + let evictedCounter: string | undefined; + for (const candidate of counts.keys()) { + if (!current.has(candidate)) { + evictedCounter = candidate; + break; + } + } + evictedCounter ??= evictedCurrentSlot; + if (evictedCounter !== undefined) counts.delete(evictedCounter); } - counts.delete(input.slotId); counts.set(input.slotId, previousCount + 1); const committed = copyRenderTraceRecord({ ...input, @@ -1105,12 +1115,9 @@ export function createRenderTraceDiagnostics( at, }); if (disposed) return committed; - if (!previous && current.size >= MAX_RENDER_TRACE_SLOTS) { - const oldestSlot = current.keys().next().value as string | undefined; - if (oldestSlot !== undefined) { - current.delete(oldestSlot); - presentation.prune(oldestSlot); - } + if (evictedCurrentSlot !== undefined) { + current.delete(evictedCurrentSlot); + presentation.prune(evictedCurrentSlot); } current.set(committed.slotId, committed); recordsBySequence.set(committed.seq, committed); diff --git a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts index 572aaa214..d88f2122c 100644 --- a/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts +++ b/crates/trusted-server-js/lib/test/core/trace_runtime.test.ts @@ -159,6 +159,44 @@ describe('render trace diagnostics runtime', () => { expect(second.count).toBe(2); }); + it('evicts the counter paired with current-state rollover without resetting retained slots', () => { + const { owner } = harness(); + for (let index = 0; index < 256; index += 1) { + owner.record({ slotId: `slot-${index}`, path: 'auction', rendered: true }); + } + const refreshedA = owner.record({ slotId: 'slot-0', path: 'ssat', rendered: true }); + expect(refreshedA.count).toBe(2); + + owner.record({ slotId: 'slot-256', path: 'auction', rendered: true }); + expect(owner.diagnostics.current()).not.toHaveProperty('slot-0'); + const refreshedB = owner.record({ slotId: 'slot-1', path: 'ssat', rendered: true }); + + expect(refreshedB.count).toBe(2); + expect(owner.diagnostics.current()['slot-1']?.count).toBe(2); + expect(Object.values(owner.diagnostics.current()).every(({ count }) => count >= 1)).toBe(true); + }); + + it('retains pruned counts until bounded capacity requires their eviction', () => { + const { owner } = harness(); + const first = owner.record({ slotId: 'reused-slot', path: 'auction', rendered: true }); + expect(owner.prune('reused-slot', first.seq)).toBe(true); + const second = owner.record({ slotId: 'reused-slot', path: 'ssat', rendered: true }); + expect(second.count).toBe(2); + expect(owner.prune('reused-slot', second.seq)).toBe(true); + + for (let index = 0; index < 255; index += 1) { + owner.record({ slotId: `capacity-${index}`, path: 'auction', rendered: true }); + } + owner.record({ slotId: 'capacity-255', path: 'auction', rendered: true }); + const afterBoundedEviction = owner.record({ + slotId: 'reused-slot', + path: 'auction', + rendered: true, + }); + + expect(afterBoundedEviction.count).toBe(1); + }); + it('retains impression bookkeeping and refuses truth-weakening enrichment', () => { const { owner } = harness(); const record = owner.record({ From 372a570f4265b711fb11410e40b35b6158afad96 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:59:44 -0700 Subject: [PATCH 407/494] Capture GPT subscriber membership at commit --- .../src/integrations/gpt_diagnostics/api.ts | 4 +- .../src/integrations/gpt_diagnostics/store.ts | 13 ++++ .../integrations/gpt_diagnostics/api.test.ts | 74 +++++++++++++++++++ .../gpt_diagnostics/store.test.ts | 21 ++++++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts index 5b1bbcb3c..0cd2d62d1 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts @@ -6,7 +6,7 @@ import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { snapshot(): GptDiagnosticsStoreSnapshot; - subscribe(listener: () => void): () => void; + subscribeCommits(listener: () => void): () => void; } interface ApiBindingManager { @@ -76,7 +76,7 @@ export class GptDiagnosticsApiController { this.document = options.document ?? document; this.now = options.now ?? (() => new Date()); this.schedule = options.schedule ?? scheduleTask; - this.unsubscribeStore = this.store.subscribe(() => this.scheduleNotification()); + this.unsubscribeStore = this.store.subscribeCommits(() => this.scheduleNotification()); this.unsubscribeBindings = this.bindings.subscribe(() => this.scheduleNotification()); this.api = Object.freeze({ diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts index 9183b6e09..add2c591d 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts @@ -140,6 +140,7 @@ export class GptDiagnosticsStore { private readonly slots = new Map(); private readonly slotOrder: number[] = []; private readonly slotActivityOrder: number[] = []; + private readonly commitListeners = new Set(); private readonly listeners = new Set(); private readonly coverage = emptyCoverage(); private readonly callbackIssues: GptDiagnosticsCallbackIssue[] = []; @@ -168,6 +169,11 @@ export class GptDiagnosticsStore { return () => this.listeners.delete(listener); } + subscribeCommits(listener: StoreListener): () => void { + this.commitListeners.add(listener); + return () => this.commitListeners.delete(listener); + } + recordSlotRequested(slot: GptDiagnosticsSlotLike, observedAtMs?: number): void { const timestampMs = this.timestamp(observedAtMs); const record = this.prepareCallback('slotRequested', slot, timestampMs); @@ -509,6 +515,13 @@ export class GptDiagnosticsStore { } private notify(): void { + for (const listener of [...this.commitListeners]) { + try { + listener(); + } catch { + // One correctness observer must not block the committed store mutation. + } + } if (this.notificationScheduled) return; this.notificationScheduled = true; this.schedule(() => { diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts index b6d90f4b4..de83d106a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts @@ -192,6 +192,80 @@ describe('GptDiagnosticsApiController', () => { ); }); + it('excludes a subscriber registered after the store commit but before source microtasks', () => { + const sourceTasks: Array<() => void> = []; + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => sourceTasks.push(callback), + }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + + store.recordSlotRequested(fakeSlot()); + const late = vi.fn(); + controller.api.subscribe(late); + while (sourceTasks.length > 0) sourceTasks.shift()?.(); + while (publicTasks.length > 0) publicTasks.shift()?.(); + + expect(late).not.toHaveBeenCalled(); + }); + + it('includes a subscriber registered before the store commit without calling it inline', () => { + const sourceTasks: Array<() => void> = []; + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => sourceTasks.push(callback), + }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + const listener = vi.fn(); + controller.api.subscribe(listener); + + store.recordSlotRequested(fakeSlot()); + expect(listener).not.toHaveBeenCalled(); + while (sourceTasks.length > 0) sourceTasks.shift()?.(); + expect(listener).not.toHaveBeenCalled(); + while (publicTasks.length > 0) publicTasks.shift()?.(); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('defers a subscriber registered during dispatch until the next commit', () => { + const publicTasks: Array<() => void> = []; + const store = new GptDiagnosticsStore({ now: () => 1, schedule: (callback) => callback() }); + const controller = new GptDiagnosticsApiController( + store, + new FakeBindings(), + { show: vi.fn(), hide: vi.fn() }, + { schedule: scheduleInto(publicTasks) } + ); + const second = vi.fn(); + const first = vi.fn(() => controller.api.subscribe(second)); + controller.api.subscribe(first); + + const observedSlot = fakeSlot(); + store.recordSlotRequested(observedSlot); + expect(first).not.toHaveBeenCalled(); + publicTasks.shift()?.(); + expect(first).toHaveBeenCalledOnce(); + expect(second).not.toHaveBeenCalled(); + + store.recordSlotVisibilityChanged(observedSlot, 10); + publicTasks.shift()?.(); + expect(first).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenCalledOnce(); + }); + it('validates callability before enforcing the shared 32-subscriber cap', () => { const controller = new GptDiagnosticsApiController( new GptDiagnosticsStore({ now: () => 1 }), diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts index 6cb6b0b9e..b57252a7e 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts @@ -412,6 +412,27 @@ describe('GptDiagnosticsStore', () => { expect(goodListener).toHaveBeenCalledTimes(1); }); + it('announces correctness commits synchronously while coalescing presentation work', () => { + const scheduled: Array<() => void> = []; + const store = new GptDiagnosticsStore({ + now: () => 1, + schedule: (callback) => scheduled.push(callback), + }); + const commitListener = vi.fn(); + const presentationListener = vi.fn(); + store.subscribeCommits(commitListener); + store.subscribe(presentationListener); + + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('commit-membership')); + + expect(commitListener).toHaveBeenCalledTimes(2); + expect(presentationListener).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(presentationListener).toHaveBeenCalledOnce(); + }); + it('returns detached snapshot data', () => { const store = new GptDiagnosticsStore({ now: () => 1 }); const slot = fakeSlot('detached'); From 8cf810c4b0caf177b1f7f0189d168551bff59d8d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:02:40 -0700 Subject: [PATCH 408/494] Exercise maximal runtime failure isolation --- .../test/composition/maximal-runtime.test.ts | 415 +++++++++++++++++- 1 file changed, 414 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts index 3a1f507f5..2b9f4816d 100644 --- a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -53,7 +53,8 @@ function generatedIntegrationIds(): readonly string[] { function tracedRegistration( registration: IntegrationRegistration, - events: string[] + events: string[], + failAfterActivation?: string ): IntegrationRegistration { return Object.freeze({ id: registration.id, @@ -66,6 +67,9 @@ function tracedRegistration( events.push(`activate:${registration.id}`); activationContext.onDispose(() => events.push(`dispose:${registration.id}`)); prepared.activate(activationContext); + if (registration.id === failAfterActivation) { + throw new Error(`injected ${registration.id} activation failure`); + } }, }); }, @@ -85,6 +89,246 @@ function integrationConfig(id: string): unknown { return undefined; } +interface MaximalHarnessOptions { + readonly configOverrides?: Readonly>; + readonly failAfterActivation?: string; +} + +interface TrackedListener { + readonly capture: boolean; + readonly listener: EventListenerOrEventListenerObject; + readonly target: EventTarget; + readonly type: string; +} + +function captureOption(options?: boolean | AddEventListenerOptions): boolean { + return typeof options === 'boolean' ? options : options?.capture === true; +} + +function createMaximalHarness(options: MaximalHarnessOptions = {}) { + const integrationIds = generatedIntegrationIds(); + const events: string[] = []; + const registrations = integrationIds.map((id) => { + const factory = REGISTRATION_FACTORIES.get(id); + if (!factory) throw new Error(`Missing real registration factory for ${id}`); + return tracedRegistration(factory(TEST_RELEASE_ID), events, options.failAfterActivation); + }); + // JSDOM lazily installs its selector engine's own document-scoped listeners. + // Materialize that test-environment infrastructure before tracking runtime effects. + document.querySelectorAll('[id]'); + const activeObservers = new Set(); + const activeMutationObservers = new Set(); + const listenerRecords: TrackedListener[] = []; + const eventTargetPrototype = EventTarget.prototype; + const addDescriptor = Object.getOwnPropertyDescriptor(eventTargetPrototype, 'addEventListener'); + const removeDescriptor = Object.getOwnPropertyDescriptor( + eventTargetPrototype, + 'removeEventListener' + ); + if ( + !addDescriptor || + !('value' in addDescriptor) || + typeof addDescriptor.value !== 'function' || + !removeDescriptor || + !('value' in removeDescriptor) || + typeof removeDescriptor.value !== 'function' + ) { + throw new Error('EventTarget listener intrinsics are unavailable'); + } + const nativeAdd = addDescriptor.value as EventTarget['addEventListener']; + const nativeRemove = removeDescriptor.value as EventTarget['removeEventListener']; + Object.defineProperty(eventTargetPrototype, 'addEventListener', { + ...addDescriptor, + value: function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + listenerOptions?: boolean | AddEventListenerOptions + ): void { + Reflect.apply(nativeAdd, this, [type, listener, listenerOptions]); + if (this !== window && this !== document) return; + const capture = captureOption(listenerOptions); + if ( + !listenerRecords.some( + (record) => + record.target === this && + record.type === type && + record.listener === listener && + record.capture === capture + ) + ) { + listenerRecords.push({ capture, listener, target: this, type }); + } + }, + }); + Object.defineProperty(eventTargetPrototype, 'removeEventListener', { + ...removeDescriptor, + value: function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + listenerOptions?: boolean | EventListenerOptions + ): void { + Reflect.apply(nativeRemove, this, [type, listener, listenerOptions]); + const capture = captureOption(listenerOptions); + const index = listenerRecords.findIndex( + (record) => + record.target === this && + record.type === type && + record.listener === listener && + record.capture === capture + ); + if (index >= 0) listenerRecords.splice(index, 1); + }, + }); + + const NativeMutationObserver = window.MutationObserver; + class TrackedMutationObserver extends NativeMutationObserver { + public constructor(callback: MutationCallback) { + super(callback); + activeMutationObservers.add(this); + } + + public override disconnect(): void { + activeMutationObservers.delete(this); + super.disconnect(); + } + } + vi.stubGlobal('MutationObserver', TrackedMutationObserver); + + let activeCaptureListeners = 0; + const googletag = Object.freeze({ + ...createNoopGoogletagAdapter(), + observeDiagnostics: (observer: GoogletagDiagnosticsObserver) => { + activeObservers.add(observer); + return (): void => { + activeObservers.delete(observer); + }; + }, + }); + const messaging = Object.freeze({ + ...createNoopMessagingAdapter(), + installCaptureListener: (listener: (event: MessageEvent) => void) => { + activeCaptureListeners += 1; + window.addEventListener('message', listener, true); + let active = true; + return (): void => { + if (!active) return; + active = false; + activeCaptureListeners -= 1; + window.removeEventListener('message', listener, true); + }; + }, + }); + const target: Record = {}; + const appendChildBefore = Element.prototype.appendChild; + const insertBeforeBefore = Element.prototype.insertBefore; + const fetchBefore = Object.getOwnPropertyDescriptor(window, 'fetch'); + const sendBeaconBefore = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const didomiBefore = Object.getOwnPropertyDescriptor(window, 'didomiConfig'); + const testlightBefore = Object.getOwnPropertyDescriptor(window, 'testlight'); + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId: TEST_RELEASE_ID, + manifest: { + version: 1, + releaseId: TEST_RELEASE_ID, + integrations: integrationIds.map((id) => ({ id, required: true })), + }, + knownIntegrationIds: integrationIds, + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + bids: [], + }, + creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: true } }, + }, + getBindings: (id) => + Object.freeze({ + config: + options.configOverrides !== undefined && + Object.prototype.hasOwnProperty.call(options.configOverrides, id) + ? options.configOverrides?.[id] + : integrationConfig(id), + interfaces: Object.freeze({}), + }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag, + messaging, + prebid: createNoopPrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + } + ); + + expect(composition.runtime.start()).toBe(true); + expect(composition.runtime.start()).toBe(false); + for (const registration of registrations) { + expect(composition.runtime.registerIntegration(registration)).toBe(true); + events.push(`register:${registration.id}`); + } + + const assertReleased = async (): Promise => { + composition.runtime.dispose(); + composition.runtime.dispose(); + await Promise.resolve(); + expect(activeObservers.size).toBe(0); + expect(activeMutationObservers.size).toBe(0); + expect(activeCaptureListeners).toBe(0); + expect( + listenerRecords.map(({ capture, target: listenerTarget, type }) => ({ + capture, + target: listenerTarget.constructor.name, + type, + })) + ).toEqual([]); + expect(composition.auctionContextRegistryForTest()).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + expect(Element.prototype.appendChild).toBe(appendChildBefore); + expect(Element.prototype.insertBefore).toBe(insertBeforeBefore); + expect(Object.getOwnPropertyDescriptor(window, 'fetch')).toEqual(fetchBefore); + expect(Object.getOwnPropertyDescriptor(navigator, 'sendBeacon')).toEqual(sendBeaconBefore); + expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); + expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); + }; + + const restoreInstrumentation = (): void => { + for (const record of [...listenerRecords]) { + try { + Reflect.apply(nativeRemove, record.target, [record.type, record.listener, record.capture]); + } catch { + // Test cleanup must not hide the first assertion failure. + } + } + listenerRecords.length = 0; + for (const observer of [...activeMutationObservers]) observer.disconnect(); + Object.defineProperty(eventTargetPrototype, 'addEventListener', addDescriptor); + Object.defineProperty(eventTargetPrototype, 'removeEventListener', removeDescriptor); + }; + + return Object.freeze({ + assertReleased, + composition, + events, + integrationIds, + resourceCounts: () => + Object.freeze({ + captureListeners: activeCaptureListeners, + listeners: listenerRecords.length, + mutationObservers: activeMutationObservers.size, + observers: activeObservers.size, + }), + restoreInstrumentation, + target, + }); +} + describe('generated maximal browser runtime transaction', () => { afterEach(() => { vi.useRealTimers(); @@ -236,4 +480,173 @@ describe('generated maximal browser runtime transaction', () => { expect(Object.getOwnPropertyDescriptor(window, 'didomiConfig')).toEqual(didomiBefore); expect(Object.getOwnPropertyDescriptor(window, 'testlight')).toEqual(testlightBefore); }); + + it.each([ + { + name: 'a real activation fails after acquiring its composed effects', + failureId: 'permutive', + phase: 'activate' as const, + }, + { + name: 'one real registration receives malformed frozen config', + failureId: 'sourcepoint', + phase: 'prepare' as const, + }, + ])('fails closed when $name', async ({ failureId, phase }) => { + vi.useFakeTimers(); + const harness = createMaximalHarness( + phase === 'activate' + ? { failAfterActivation: failureId } + : { + configOverrides: Object.freeze({ + [failureId]: Object.freeze({ rewriteSdk: 'yes' }), + }), + } + ); + try { + const installed = await harness.composition.runtime.install(); + const failureIndex = harness.integrationIds.indexOf(failureId); + const preparedIds = + phase === 'activate' + ? harness.integrationIds + : harness.integrationIds.slice(0, failureIndex + 1); + const activatedIds = + phase === 'activate' ? harness.integrationIds.slice(0, failureIndex + 1) : []; + + expect(installed).toEqual({ state: 'fallback', reason: 'bundle_partial' }); + expect(harness.composition.runtime.state).toBe('fallback'); + expect(harness.target['_internal']).toMatchObject({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(harness.events.filter((event) => event.startsWith('register:'))).toEqual( + harness.integrationIds.map((id) => `register:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + preparedIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + activatedIds.map((id) => `activate:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...activatedIds].reverse().map((id) => `dispose:${id}`) + ); + + await harness.assertReleased(); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + [...activatedIds].reverse().map((id) => `dispose:${id}`) + ); + } finally { + harness.restoreInstrumentation(); + } + }); + + it.each([ + { name: 'missing SDK globals reach their bounded readiness timeouts', kind: 'readiness' }, + { name: 'hostile consent storage fails only its after-commit owner', kind: 'storage' }, + { + name: 'matcher false positives and throwing publisher callbacks stay isolated', + kind: 'matcher', + }, + ] as const)('isolates $name across all real registrations', async ({ kind }) => { + vi.useFakeTimers(); + const callbackOrder: string[] = []; + const publisherBinding: { target?: Record } = {}; + let falsePositiveScript: HTMLScriptElement | undefined; + if (kind === 'readiness') { + vi.stubGlobal('identityLockr', undefined); + vi.stubGlobal('permutive', undefined); + } + if (kind === 'storage') { + vi.stubGlobal( + 'localStorage', + new Proxy({} as Storage, { + get: () => { + throw new Error('publisher storage is unavailable'); + }, + }) + ); + } + if (kind === 'matcher') { + vi.stubGlobal('testlight', { + que: [ + function (this: unknown): void { + callbackOrder.push(this === publisherBinding.target ? 'throw:bound' : 'throw:unbound'); + throw new Error('publisher queue callback failed'); + }, + function (this: unknown): void { + callbackOrder.push( + this === publisherBinding.target ? 'survive:bound' : 'survive:unbound' + ); + }, + ], + }); + } + const harness = createMaximalHarness(); + publisherBinding.target = harness.target; + try { + const installed = await harness.composition.runtime.install(); + const expectedRuntimeFailures = + kind === 'storage' ? [{ id: 'sourcepoint', phase: 'after_commit' }] : []; + + expect(installed).toEqual({ + state: 'kernel', + runtimeFailures: expectedRuntimeFailures, + dispose: expect.any(Function), + }); + expect(harness.composition.runtime.state).toBe('kernel'); + expect(harness.events.filter((event) => event.startsWith('prepare:'))).toEqual( + harness.integrationIds.map((id) => `prepare:${id}`) + ); + expect(harness.events.filter((event) => event.startsWith('activate:'))).toEqual( + harness.integrationIds.map((id) => `activate:${id}`) + ); + expect( + harness.composition.auctionContextRegistryForTest()?.snapshotInventoryForTest() + ).toEqual({ disposed: false, registrations: ['permutive'] }); + expect(harness.resourceCounts()).toMatchObject({ + captureListeners: 1, + listeners: expect.any(Number), + mutationObservers: expect.any(Number), + observers: 1, + }); + expect(harness.resourceCounts().listeners).toBeGreaterThan(0); + expect(harness.resourceCounts().mutationObservers).toBeGreaterThan(0); + + if (kind === 'readiness') { + await vi.runAllTimersAsync(); + expect(harness.composition.runtime.state).toBe('kernel'); + expect(vi.getTimerCount()).toBe(0); + } + if (kind === 'matcher') { + expect(callbackOrder).toEqual(['throw:bound', 'survive:bound']); + falsePositiveScript = document.createElement('script'); + const originalUrl = 'https://publisher.example/assets/www.googletagmanager.com/gtm.js'; + falsePositiveScript.src = originalUrl; + document.head.appendChild(falsePositiveScript); + expect(falsePositiveScript.src).toBe(originalUrl); + } + + falsePositiveScript?.remove(); + const disposedBeforeRuntimeRelease = kind === 'storage' ? ['dispose:sourcepoint'] : []; + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + disposedBeforeRuntimeRelease + ); + await harness.assertReleased(); + const reverseIds = [...harness.integrationIds].reverse(); + const expectedDisposals = + kind === 'storage' + ? [ + 'dispose:sourcepoint', + ...reverseIds.filter((id) => id !== 'sourcepoint').map((id) => `dispose:${id}`), + ] + : reverseIds.map((id) => `dispose:${id}`); + expect(harness.events.filter((event) => event.startsWith('dispose:'))).toEqual( + expectedDisposals + ); + } finally { + falsePositiveScript?.remove(); + harness.restoreInstrumentation(); + } + }); }); From 6eb445c78e6fa58b4ba8f079c4b2eca75fdf37fb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:37:09 -0700 Subject: [PATCH 409/494] Switch production to the resilient TSJS runtime --- crates/trusted-server-adapter-axum/src/app.rs | 34 +- .../trusted-server-adapter-axum/src/main.rs | 72 +- .../tests/routes.rs | 16 +- .../src/app.rs | 30 +- .../src/lib.rs | 11 +- .../tests/routes.rs | 16 +- .../trusted-server-adapter-fastly/src/app.rs | 74 +- .../trusted-server-adapter-fastly/src/main.rs | 6 - crates/trusted-server-adapter-spin/src/app.rs | 32 +- crates/trusted-server-adapter-spin/src/lib.rs | 1 - .../tests/routes.rs | 72 +- .../src/auction/endpoints.rs | 118 +- .../src/auction/formats.rs | 91 +- .../trusted-server-core/src/auction/types.rs | 18 + crates/trusted-server-core/src/auth.rs | 5 +- .../trusted-server-core/src/html_processor.rs | 259 +- .../src/integrations/aps.rs | 16 +- .../src/integrations/didomi.rs | 12 +- .../src/integrations/gpt.rs | 57 +- .../src/integrations/mod.rs | 18 +- .../src/integrations/prebid.rs | 11 +- .../src/integrations/registry.rs | 56 +- .../src/integrations/sourcepoint.rs | 116 +- .../trusted-server-core/src/platform/mod.rs | 1 - .../trusted-server-core/src/platform/types.rs | 1 - crates/trusted-server-core/src/publisher.rs | 1117 ++--- crates/trusted-server-core/src/tsjs.rs | 154 + crates/trusted-server-js/lib/build-all.mjs | 16 +- .../lib/src/adapters/googletag.ts | 112 + .../lib/src/composition/browser.ts | 490 +- .../lib/src/composition/index.ts | 7 + .../trusted-server-js/lib/src/core/auction.ts | 2 + .../src/core/contracts/auction_projection.ts | 52 +- .../trusted-server-js/lib/src/core/index.ts | 246 +- .../trusted-server-js/lib/src/core/release.ts | 6 + .../trusted-server-js/lib/src/core/types.ts | 10 + .../lib/src/integrations/creative/index.ts | 44 +- .../lib/src/integrations/datadome/index.ts | 28 +- .../lib/src/integrations/didomi/index.ts | 9 +- .../integrations/google_tag_manager/index.ts | 36 +- .../lib/src/integrations/gpt/index.ts | 108 +- .../lib/src/integrations/gpt/module.ts | 68 +- .../src/integrations/gpt_diagnostics/index.ts | 12 + .../lib/src/integrations/lockr/index.ts | 110 +- .../lib/src/integrations/osano/index.ts | 13 +- .../lib/src/integrations/permutive/index.ts | 119 +- .../lib/src/integrations/prebid/index.ts | 31 +- .../lib/src/integrations/sourcepoint/index.ts | 20 +- .../lib/src/integrations/testlight/index.ts | 87 +- .../lib/src/kernel/fallback.ts | 1 + .../lib/src/kernel/runtime.ts | 6 +- .../lib/src/services/projections.ts | 43 +- .../lib/src/services/slots.ts | 30 +- .../lib/test/adapters/googletag.test.ts | 66 + .../lib/test/composition/browser.test.ts | 501 ++- .../test/composition/maximal-runtime.test.ts | 2 + .../lib/test/core/auction.test.ts | 35 +- .../lib/test/core/index.test.ts | 169 +- .../test/integrations/creative/click.test.ts | 18 +- .../lib/test/integrations/creative/helpers.ts | 44 +- .../test/integrations/creative/iframe.test.ts | 6 +- .../test/integrations/creative/image.test.ts | 6 +- .../lib/test/integrations/gpt/ad_init.test.ts | 3932 ----------------- .../integrations/gpt/gpt_bootstrap.test.ts | 10 + .../lib/test/integrations/gpt/index.test.ts | 453 -- .../lib/test/integrations/gpt/module.test.ts | 15 + .../gpt/schedule_initial_ad_init.test.ts | 347 -- .../test/integrations/gpt/spa_hook.test.ts | 625 --- .../test/integrations/prebid/index.test.ts | 93 - .../integrations/sourcepoint/index.test.ts | 56 +- .../lib/test/kernel/fallback.test.ts | 1 + .../lib/test/kernel/runtime.test.ts | 13 + .../test/prebid-artifact-integration.test.mjs | 154 - .../lib/test/services/projections.test.ts | 46 +- .../lib/test/services/slots.test.ts | 2 + crates/trusted-server-js/lib/vitest.config.ts | 12 + ...8-04-aps-tsjs-resilience-implementation.md | 36 +- ...s-render-fix-and-tsjs-resilience-design.md | 97 +- 78 files changed, 3204 insertions(+), 7655 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/composition/index.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index ee7f57b50..28d429e13 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -19,8 +19,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, buffer_publisher_response_async, - handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, buffer_publisher_response_async, handle_page_bids, + handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -71,9 +71,6 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -83,7 +80,6 @@ fn build_state_with_settings( })) } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { if !state.registry.has_reserved_path(req.uri().path()) { return None; @@ -95,25 +91,23 @@ async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Opt .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[derive(Clone)] -/// Feature-artifact dispatcher that owns one startup-built APS registry. +/// Dispatcher that owns one startup-built registry for hard-cutover route families. pub struct ReservedApsDispatcher { state: Arc, } -#[cfg(feature = "aps-runner-proxy-integration-test")] impl ReservedApsDispatcher { /// Build the dispatcher from the adapter's startup settings. /// /// # Errors /// - /// Returns an error when settings, the orchestrator, or the APS test + /// Returns an error when settings, the orchestrator, or the integration /// registry cannot be initialized. pub fn from_startup_settings() -> Result> { Ok(Self { @@ -125,7 +119,7 @@ impl ReservedApsDispatcher { /// /// # Errors /// - /// Returns an error when the orchestrator or APS test registry cannot be + /// Returns an error when the orchestrator or integration registry cannot be /// initialized from `settings`. pub fn from_settings(settings: Settings) -> Result> { Ok(Self { @@ -139,12 +133,11 @@ impl ReservedApsDispatcher { } } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using explicit settings. /// /// # Errors /// -/// Returns an error when the feature-only dispatcher cannot be initialized. +/// Returns an error when the dispatcher cannot be initialized. pub async fn dispatch_reserved_with_settings( settings: Settings, req: Request, @@ -154,12 +147,11 @@ pub async fn dispatch_reserved_with_settings( .await) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using startup settings. /// /// # Errors /// -/// Returns an error when startup settings or the feature-only dispatcher +/// Returns an error when startup settings or the dispatcher /// cannot be initialized. pub async fn dispatch_reserved( req: Request, @@ -377,7 +369,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 14] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -435,14 +427,6 @@ fn named_routes() -> [NamedRoute; 14] { primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias, kept so tsjs bundles served before - // the `/_ts/page-bids` rename keep getting ads on SPA navigations until - // they age out of browser caches. See `PAGE_BIDS_LEGACY_PATH`. - NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, - }, NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 8e22dedd4..7e0efdd37 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,11 +1,14 @@ -#[cfg(not(feature = "aps-runner-proxy-integration-test"))] -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_adapter_axum::dev_server::AxumDevServerConfig; use edgezero_core::app::Hooks as _; use trusted_server_adapter_axum::app::TrustedServerApp; -#[cfg(not(feature = "aps-runner-proxy-integration-test"))] +#[tokio::main] #[allow(clippy::print_stderr)] -fn main() { +async fn main() { + use axum::Router; + use axum::routing::any; + use edgezero_adapter_axum::service::EdgeZeroAxumService; + if let Err(e) = simple_logger::SimpleLogger::new().init() { eprintln!("warning: logger init failed: {e}"); } @@ -21,48 +24,27 @@ fn main() { None => AxumDevServerConfig::default(), }; - log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { - log::error!("trusted-server-adapter-axum failed: {err}"); - std::process::exit(1); - } -} - -#[cfg(feature = "aps-runner-proxy-integration-test")] -#[tokio::main] -#[allow(clippy::print_stderr)] -async fn main() { - use axum::Router; - use axum::routing::any; - use edgezero_adapter_axum::service::EdgeZeroAxumService; - - if let Err(e) = simple_logger::SimpleLogger::new().init() { - eprintln!("warning: logger init failed: {e}"); - } - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port_from_env().unwrap_or(8787))); let dispatcher = trusted_server_adapter_axum::app::ReservedApsDispatcher::from_startup_settings() - .expect("APS feature artifact should build its reserved dispatcher"); + .expect("should build the reserved APS dispatcher"); let reserved = any(move |request: axum::http::Request| { let dispatcher = dispatcher.clone(); async move { let response = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { - let request = match edgezero_adapter_axum::request::into_core_request(request) - .await - { - Ok(request) => request, - Err(error) => { - log::warn!("reserved APS request conversion failed: {error:?}"); - return Err(axum::http::StatusCode::BAD_REQUEST); - } - }; + let request = + match edgezero_adapter_axum::request::into_core_request(request).await { + Ok(request) => request, + Err(error) => { + log::warn!("reserved APS request conversion failed: {error:?}"); + return Err(axum::http::StatusCode::BAD_REQUEST); + } + }; match dispatcher.dispatch(request).await { Some(response) => Ok(response), None => { log::error!( - "reserved APS entry route reached a request outside its route family" + "reserved APS entry route reached a request outside its family" ); Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) } @@ -79,11 +61,23 @@ async fn main() { .route("/integrations/aps", reserved.clone()) .route("/integrations/aps/{*rest}", reserved) .fallback_service(EdgeZeroAxumService::new(TrustedServerApp::routes())); - let listener = tokio::net::TcpListener::bind(addr) + let listener = tokio::net::TcpListener::bind(config.addr) .await - .expect("APS feature artifact should bind its configured address"); - log::info!("Listening on http://{addr}"); - if let Err(error) = axum::serve(listener, app).await { + .expect("should bind the configured address"); + log::info!("Listening on http://{}", config.addr); + let server = axum::serve(listener, app); + let result = if config.enable_ctrl_c { + server + .with_graceful_shutdown(async { + if let Err(error) = tokio::signal::ctrl_c().await { + log::error!("failed to install Ctrl-C handler: {error}"); + } + }) + .await + } else { + server.await + }; + if let Err(error) = result { log::error!("trusted-server-adapter-axum failed: {error}"); } } diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c1fc7e28f..0e16bcfab 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -54,7 +54,6 @@ fn test_router() -> edgezero_core::router::RouterService { .expect("should build router from test settings") } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn route_reserved(request: Request) -> axum::http::Response { let request = edgezero_adapter_axum::request::into_core_request(request) .await @@ -102,16 +101,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), - // 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 - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -123,6 +115,11 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + let routes = registered_routes(); + assert!( + routes.iter().all(|(_, path)| path != "/__ts/page-bids"), + "hard cutover must not retain the deprecated page-bids alias: {routes:?}" + ); } /// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local @@ -233,7 +230,6 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = Request::builder() diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index fe8b618f1..a29a58fee 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -21,9 +21,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -109,9 +108,6 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -121,7 +117,6 @@ fn build_state_with_settings( })) } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { if !state.registry.has_reserved_path(req.uri().path()) { return None; @@ -133,12 +128,11 @@ async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Opt .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved request using explicit settings. /// /// # Errors @@ -152,7 +146,6 @@ pub async fn dispatch_reserved_with_settings( Ok(dispatch_reserved_for_state(&state, req).await) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved request using the configured adapter state. /// /// # Errors @@ -591,15 +584,8 @@ fn build_router(state: &Arc) -> RouterService { }), ); - // SPA re-auction endpoint, registered on the canonical path and on the - // deprecated `PAGE_BIDS_LEGACY_PATH` double-underscore alias. The alias - // keeps tsjs bundles served before the `/_ts/page-bids` rename getting - // ads on SPA navigations until they age out of browser caches. - // - // The OPTIONS preflight is denied on both so the GET handler's - // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the - // preflight fall through to a permissive origin would reopen exactly - // the cross-site hole the canonical path closes. + // SPA re-auction endpoint. OPTIONS is denied so the GET handler's + // `X-TSJS-Page-Bids` gate stays trustworthy. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { let ec_context = build_ec_context(&s.settings, &services, &req); let auction = AuctionDispatch { @@ -613,10 +599,8 @@ fn build_router(state: &Arc) -> RouterService { make_handler(Arc::clone(&state), |_s, _services, _req| async move { Ok(page_bids_preflight_denied()) }); - for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { - router = router.route(path, Method::GET, page_bids.clone()); - router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); - } + router = router.route(PAGE_BIDS_PATH, Method::GET, page_bids); + router = router.route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_preflight); let legacy_admin_deny = make_handler(Arc::clone(&state), |_s, _services, _req| async move { diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index b28f40cbb..3ab7d3434 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -15,10 +15,7 @@ pub mod platform; #[cfg(target_arch = "wasm32")] use worker::{Context, Env, Request, Response, Result, event}; -#[cfg(all( - feature = "aps-runner-proxy-integration-test", - any(target_arch = "wasm32", test) -))] +#[cfg(any(target_arch = "wasm32", test))] fn preserved_reserved_method(value: &str) -> Option { edgezero_core::http::Method::from_bytes(value.as_bytes()).ok() } @@ -36,11 +33,9 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { app::set_cloudflare_config_json(config.to_string()); } - #[cfg(feature = "aps-runner-proxy-integration-test")] let is_reserved = req .url() .is_ok_and(|url| trusted_server_core::integrations::aps::is_aps_family_path(url.path())); - #[cfg(feature = "aps-runner-proxy-integration-test")] if is_reserved { // workers-rs maps unknown methods to GET; the underlying Fetch request // preserves the original method token, so capture it before conversion. @@ -56,7 +51,7 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { .map_err(|error| worker::Error::RustError(error.to_string()))? .ok_or_else(|| { worker::Error::RustError( - "reserved APS path has no coordinated-cutover handler".to_string(), + "reserved APS path has no hard-cutover handler".to_string(), ) })?; return edgezero_adapter_cloudflare::response::from_core_response(response) @@ -72,7 +67,7 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } } -#[cfg(all(test, feature = "aps-runner-proxy-integration-test"))] +#[cfg(test)] mod tests { use super::preserved_reserved_method; diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index dbbea3288..0c2c3db6f 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -70,7 +70,6 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn route_reserved(req: Request) -> Response { trusted_server_adapter_cloudflare::app::dispatch_reserved_with_settings(test_settings(), req) .await @@ -121,7 +120,6 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() @@ -303,16 +301,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("POST", "/auction"), - // 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 - // actual URL the tsjs client fetches — asserting a const against itself - // would still pass if the const's value changed out from under the - // client. + // Pin the canonical literal fetched by the hard-cutover client. ("GET", "/_ts/page-bids"), ("OPTIONS", "/_ts/page-bids"), - ("GET", "/__ts/page-bids"), - ("OPTIONS", "/__ts/page-bids"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), ("GET", "/first-party/sign"), @@ -324,6 +315,11 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } + let routes = registered_routes(); + assert!( + routes.iter().all(|(_, path)| path != "/__ts/page-bids"), + "hard cutover must not retain the deprecated page-bids alias: {routes:?}" + ); for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { for method in LEGACY_ADMIN_DENY_METHODS { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ab1260257..b61566b55 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -118,9 +118,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, - publisher_response_into_streaming_response, + AuctionDispatch, PAGE_BIDS_PATH, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, publisher_response_into_streaming_response, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -165,7 +164,6 @@ pub(crate) fn build_state() -> Result, Report> build_state_from_settings(load_settings_from_config_store()?) } -#[cfg(feature = "aps-runner-proxy-integration-test")] pub(crate) async fn dispatch_reserved_for_state( state: &Arc, req: Request, @@ -180,7 +178,7 @@ pub(crate) async fn dispatch_reserved_for_state( .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } @@ -197,9 +195,6 @@ pub(crate) fn build_state_from_settings( warn_if_certificate_check_disabled(&settings); let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); @@ -1133,16 +1128,6 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET, Method::OPTIONS], handler: NamedRouteHandler::PageBids, }, - // Deprecated double-underscore alias. tsjs bundles served before the - // `/_ts/page-bids` rename keep requesting this path from already-loaded - // pages and browser caches; dropping it would strand SPA navigations - // without ads until those bundles age out. See `PAGE_BIDS_LEGACY_PATH`; - // removal is tracked by IABTechLab/trusted-server#970. - NamedRoute { - path: PAGE_BIDS_LEGACY_PATH, - primary_methods: &[Method::GET, Method::OPTIONS], - handler: NamedRouteHandler::PageBids, - }, NamedRoute { path: "/first-party/proxy", primary_methods: &[Method::GET], @@ -1272,8 +1257,8 @@ mod tests { #[cfg(feature = "aps-runner-proxy-integration-test")] use super::dispatch_reserved_for_state; use super::{ - AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, - TrustedServerApp, build_state_from_settings, startup_error_router, + AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp, + build_state_from_settings, startup_error_router, }; use bytes::Bytes; use edgezero_core::body::Body; @@ -1780,45 +1765,26 @@ mod tests { } #[test] - fn page_bids_serves_canonical_path_and_deprecated_alias() { - // The SPA re-auction endpoint lives at the canonical single-underscore - // `/_ts/page-bids`, matching every other internal route. The deprecated - // `/__ts/page-bids` alias must stay registered to the same handler with - // the same methods until pre-rename tsjs bundles age out of browser - // caches — dropping it would leave those clients without ads on SPA - // navigations. - // - // The paths are literals, not `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`. - // Looking a route up by the same const it was registered with is - // tautological: it keeps passing if the const's value changes, which is - // exactly the break that would silently desync the server from the tsjs - // client's hardcoded fetch path. Pin the consts to their literals too so - // a rename has to be deliberate. + fn page_bids_serves_only_the_canonical_path() { + // The hard cutover exposes only the canonical single-underscore path. + // Pin the literal the client fetches and reject accidental reintroduction + // of the former compatibility alias. assert_eq!( PAGE_BIDS_PATH, "/_ts/page-bids", "canonical page-bids path must match the path tsjs fetches" ); - assert_eq!( - PAGE_BIDS_LEGACY_PATH, "/__ts/page-bids", - "legacy alias must match the path pre-rename tsjs bundles fetch" - ); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let route = NAMED_ROUTES + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == "/_ts/page-bids") + .expect("canonical page-bids path should be registered"); + assert!(matches!(route.handler, NamedRouteHandler::PageBids)); + assert_eq!(route.primary_methods, &[Method::GET, Method::OPTIONS]); + assert!( + NAMED_ROUTES .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} should be registered")); - - assert!( - matches!(route.handler, NamedRouteHandler::PageBids), - "{path} must map to the page-bids handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET, Method::OPTIONS], - "{path} must handle GET and OPTIONS directly, not fall through to the publisher" - ); - } + .all(|route| route.path != "/__ts/page-bids"), + "hard cutover must not retain the deprecated page-bids alias" + ); } #[test] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 352506874..94b22e3bd 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -167,7 +167,6 @@ fn edgezero_main(mut req: FastlyRequest) { core_req.extensions_mut().insert(config_store); core_req.extensions_mut().insert(device_signals); core_req.extensions_mut().insert(client_info); - #[cfg(feature = "aps-runner-proxy-integration-test")] let routed = if let Some(state) = app_state .as_ref() .filter(|state| state.registry.has_reserved_path(core_req.uri().path())) @@ -181,8 +180,6 @@ fn edgezero_main(mut req: FastlyRequest) { } else { futures::executor::block_on(app.router().oneshot(core_req)) }; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] - let routed = futures::executor::block_on(app.router().oneshot(core_req)); match routed { Ok(response) => response, Err(error) => edge_error_response(error), @@ -202,14 +199,11 @@ fn edgezero_main(mut req: FastlyRequest) { let asset_cache_policy = response.extensions_mut().remove::(); let request_filter_effects = response.extensions_mut().remove::(); - #[cfg(feature = "aps-runner-proxy-integration-test")] let should_finalize = response .extensions() .get::() .is_none() && !take_finalize_sentinel(&mut response); - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] - let should_finalize = !take_finalize_sentinel(&mut response); if should_finalize { if let Some(settings) = settings_snapshot.as_deref() { apply_entry_point_finalize_headers(settings, &mut response, client_ip); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 0ce351336..245ccf173 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -22,9 +22,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, PublisherResponse, - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, PAGE_BIDS_PATH, PublisherResponse, buffer_publisher_response_async, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -82,9 +81,6 @@ fn build_state_with_settings( settings: Settings, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; - #[cfg(feature = "aps-runner-proxy-integration-test")] - let registry = IntegrationRegistry::new_with_aps_v1_for_tests(&settings)?; - #[cfg(not(feature = "aps-runner-proxy-integration-test"))] let registry = IntegrationRegistry::new(&settings)?; Ok(Arc::new(AppState { @@ -94,7 +90,6 @@ fn build_state_with_settings( })) } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Option { if !state.registry.has_reserved_path(req.uri().path()) { return None; @@ -106,17 +101,16 @@ async fn dispatch_reserved_for_state(state: &Arc, req: Request) -> Opt .registry .handle_reserved_proxy(&state.settings, &services, ctx.into_request()) .await - .expect("reserved path should have a coordinated-cutover handler") + .expect("reserved path should have a hard-cutover handler") .unwrap_or_else(|report| http_error(&report)), ) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using explicit settings. /// /// # Errors /// -/// Returns an error when the feature-only application state cannot be +/// Returns an error when the application state cannot be /// initialized from `settings`. pub async fn dispatch_reserved_with_settings( settings: Settings, @@ -126,12 +120,11 @@ pub async fn dispatch_reserved_with_settings( Ok(dispatch_reserved_for_state(&state, req).await) } -#[cfg(feature = "aps-runner-proxy-integration-test")] /// Dispatch a reserved APS request using startup settings. /// /// # Errors /// -/// Returns an error when startup settings or the feature-only application +/// Returns an error when startup settings or the application /// state cannot be initialized. pub async fn dispatch_reserved( req: Request, @@ -209,7 +202,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -220,7 +213,6 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), - (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), ("/first-party/proxy", &[Method::GET]), ("/first-party/click", &[Method::GET]), ("/first-party/sign", &[Method::GET, Method::POST]), @@ -843,18 +835,8 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) .get("/_ts/trace", trace_mode_handler) .post("/auction", auction_handler) - .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .get(PAGE_BIDS_PATH, page_bids_handler) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) - // Deprecated double-underscore alias, kept so tsjs bundles served - // before the `/_ts/page-bids` rename keep getting ads on SPA - // navigations until they age out of browser caches. See - // `PAGE_BIDS_LEGACY_PATH`. - .get(PAGE_BIDS_LEGACY_PATH, page_bids_handler) - .route( - PAGE_BIDS_LEGACY_PATH, - Method::OPTIONS, - page_bids_options_handler, - ) .get("/first-party/proxy", fp_proxy_handler) .get("/first-party/click", fp_click_handler) .get("/first-party/sign", fp_sign_handler) diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index bb43c2eff..5a6b20bc1 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -13,7 +13,6 @@ use spin_sdk::http_service; #[http_service] // FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice. async fn handle(req: Request) -> anyhow::Result { - #[cfg(feature = "aps-runner-proxy-integration-test")] if trusted_server_core::integrations::aps::is_aps_family_path(req.uri().path()) { let request = edgezero_adapter_spin::request::into_core_request(req).await?; let response = app::dispatch_reserved(request) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index e0e797f0a..c7fdf514d 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -60,7 +60,6 @@ async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } -#[cfg(feature = "aps-runner-proxy-integration-test")] async fn route_reserved(req: Request) -> Response { trusted_server_adapter_spin::app::dispatch_reserved_with_settings(test_settings(), req) .await @@ -75,7 +74,6 @@ fn routes_build_without_panic() { let _router = TrustedServerApp::routes(); } -#[cfg(feature = "aps-runner-proxy-integration-test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() @@ -417,53 +415,35 @@ async fn auction_is_routed() { assert_ne!(resp.status().as_u16(), 404, "/auction must be routed"); } -/// `GET` on the SPA re-auction endpoint must reach the page-bids handler on -/// both the canonical path and its deprecated `/__ts/` alias. -/// -/// The alias is what pre-rename tsjs bundles still request, and on a SPA that -/// path is what delivers ads for in-session navigations — so a dropped or -/// misspelled registration silently costs revenue rather than erroring loudly. -/// Spin registers `GET` and `OPTIONS` separately, so the preflight-denial parity -/// test does not imply the `GET` side is wired. -/// -/// Paths are literals rather than `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH`: -/// this pins the actual URL the client fetches, which asserting a const against -/// itself would not. -/// -/// These test settings configure no creative opportunities, so the handler's own -/// deterministic answer is a 404 `Creative opportunities not configured`. That -/// body is the anchor: an unregistered path would instead fall through to the -/// publisher fallback and attempt an outbound fetch to the (nonexistent) test -/// origin, which cannot produce this message. A bare `!= 404` check would be -/// wrong here — the handler legitimately returns 404 under this config. +/// The canonical SPA re-auction path reaches page-bids, while the hard cutover +/// leaves the former double-underscore alias to the publisher fallback. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn page_bids_get_is_routed_on_canonical_path_and_alias() { - let mut responses = Vec::new(); - - for path in ["/_ts/page-bids", "/__ts/page-bids"] { - let req = request_builder() - .method("GET") - .uri(path) - .header("sec-fetch-site", "same-origin") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - let status = resp.status().as_u16(); - let body = String::from_utf8_lossy(&resp.into_body().into_bytes().unwrap_or_default()) +async fn page_bids_get_is_routed_only_on_the_canonical_path() { + let canonical = request_builder() + .method("GET") + .uri("/_ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let canonical = route(test_router(), canonical).await; + let canonical_body = + String::from_utf8_lossy(&canonical.into_body().into_bytes().unwrap_or_default()) .into_owned(); + assert!(canonical_body.contains("Creative opportunities not configured")); - assert!( - body.contains("Creative opportunities not configured"), - "GET {path} must reach the page-bids handler, \ - got status {status} body {body:?}" - ); - - responses.push((status, body)); - } - - assert_eq!( - responses[0], responses[1], - "the deprecated alias must answer identically to the canonical path" + let former_alias = request_builder() + .method("GET") + .uri("/__ts/page-bids") + .header("sec-fetch-site", "same-origin") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let former_alias = route(test_router(), former_alias).await; + let alias_body = + String::from_utf8_lossy(&former_alias.into_body().into_bytes().unwrap_or_default()) + .into_owned(); + assert!( + !alias_body.contains("Creative opportunities not configured"), + "former compatibility alias must not reach page-bids" ); } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index bab6fe9bd..e4a16b3e0 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,6 +1,6 @@ //! HTTP endpoint handlers for auction requests. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; @@ -19,20 +19,21 @@ use crate::ec::log_id; use crate::ec::prebid_eids::parse_prebid_eids_cookie; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; +use crate::http_util::RequestInfo; use crate::openrtb::{Eid, Uid}; use crate::platform::RuntimeServices; use crate::settings::Settings; use super::AuctionOrchestrator; -use super::formats::{ - convert_to_openrtb_response, convert_to_openrtb_response_with_report, - convert_tsjs_to_auction_request, -}; +use super::formats::{attach_auction_response_headers, convert_tsjs_to_auction_request}; use super::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, }; -use super::types::{AuctionContext, AuctionDecisionSetV1, AuctionSlotFailureReason}; +use super::types::{ + AuctionContext, AuctionDecisionSetV1, AuctionRequest, AuctionSlotFailureReason, + SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, +}; const MAX_CLIENT_EID_SOURCES: usize = 64; const MAX_CLIENT_UIDS_PER_SOURCE: usize = 32; @@ -44,6 +45,66 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// arbitrary WASM linear memory. const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; +struct ExactAuctionResponseV1 { + response: Response, + delivered_winner_slots: HashSet, + dropped_winner_count: usize, +} + +fn exact_auction_response_v1( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + request_origin: &str, + ec_allowed: bool, +) -> Result> { + let price_granularity = settings + .creative_opportunities + .as_ref() + .map(|config| config.price_granularity) + .unwrap_or_default(); + let canonical = crate::publisher::coordinated_cutover_v1::build_browser_auction_projection_v1( + result, + price_granularity, + settings, + request_origin, + None, + &SystemAuctionIdentityGenerator, + )?; + let body = crate::auction::formats::coordinated_cutover_v1::serialize_trusted_server_auction_response_v1( + &canonical, + )?; + let delivered_winner_slots: HashSet = canonical + .projection + .auction + .results + .iter() + .filter_map(|decision| match decision { + SlotAuctionDecisionV1::Winner { slot, .. } => Some(slot.clone()), + _ => None, + }) + .collect(); + let projected_winner_count = result + .decision_set + .results + .iter() + .filter(|decision| matches!(decision, SlotAuctionDecisionV1::Winner { .. })) + .count(); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Auction { + message: "Failed to build exact auction response".to_string(), + })?; + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; + Ok(ExactAuctionResponseV1 { + response, + dropped_winner_count: projected_winner_count.saturating_sub(delivered_winner_slots.len()), + delivered_winner_slots, + }) +} + /// Handle auction request from `POST /auction`. /// /// Accepts a JSON body matching [`AdRequest`][`super::formats::AdRequest`]. @@ -163,6 +224,22 @@ pub async fn handle_auction( ); let http_req = Request::from_parts(parts, EdgeBody::empty()); + let request_info = RequestInfo::from_request(&http_req, services.client_info()); + let request_scheme = if request_info.scheme.is_empty() { + http_req.uri().scheme_str().unwrap_or("https") + } else { + &request_info.scheme + }; + let request_host = if request_info.host.is_empty() { + http_req + .uri() + .authority() + .map(http::uri::Authority::as_str) + .unwrap_or(&settings.publisher.domain) + } else { + &request_info.host + }; + let request_origin = format!("{request_scheme}://{request_host}"); // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. @@ -223,12 +300,14 @@ pub async fn handle_auction( total_time_ms: 0, metadata: HashMap::new(), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Parse client-provided EIDs from the current request body. When the @@ -325,10 +404,11 @@ pub async fn handle_auction( } }; - let conversion = match convert_to_openrtb_response_with_report( + let conversion = match exact_auction_response_v1( &result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), ) { Ok(conversion) => conversion, @@ -356,7 +436,7 @@ pub async fn handle_auction( AuctionTerminalOutcome::Completed { request: &auction_request, result: &result, - delivered_winner_slots: Some(&conversion.delivery.delivered_winner_slots), + delivered_winner_slots: Some(&conversion.delivered_winner_slots), }, ) }) @@ -365,8 +445,8 @@ pub async fn handle_auction( log::info!( "Auction completed: {} providers, {} delivered winning bids, {} dropped winners, {}ms total", result.provider_responses.len(), - conversion.delivery.delivered_winner_slots.len(), - conversion.delivery.dropped_winner_count, + conversion.delivered_winner_slots.len(), + conversion.dropped_winner_count, result.total_time_ms ); @@ -740,6 +820,20 @@ mod tests { seatbid_empty, "gated auction must return no bids, got: {parsed}" ); + assert_eq!(parsed["cur"], "USD"); + assert_eq!( + parsed["ext"]["trusted_server"]["slot_results"]["results"][0], + json!({ + "slot": "div-gpt-ad-1", + "outcome": "failed", + "reason": "consent_denied" + }), + "the production endpoint must emit the exact decision-set extension" + ); + assert!( + parsed["ext"].get("orchestrator").is_none(), + "the removed legacy response extension must not survive the hard cutover" + ); let batches = telemetry_sink .batches diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index bc804a2c4..e8d9b8ab6 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -31,7 +31,7 @@ use super::orchestrator::OrchestrationResult; use super::types::{ AdFormat, AdSlot, AuctionDecisionSetV1, AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionSlotFailureReason, BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, - CacheFetchPolicyV1, DeviceInfo, MAX_BROWSER_AUCTION_PROJECTION_BYTES, + BrowserAuctionSlotV1, CacheFetchPolicyV1, DeviceInfo, MAX_BROWSER_AUCTION_PROJECTION_BYTES, MAX_BROWSER_AUCTION_RESULTS, MAX_BROWSER_AUCTION_TARGETING_ENTRIES, MediaType, OrchestratorExt, ProviderSummary, PublisherInfo, RENDER_DIMENSION_MAX, RENDER_DIMENSION_MIN, SiteInfo, SlotAuctionDecisionV1, UserInfo, classify_aps_renderer_v1, record_auction_drop, @@ -311,6 +311,35 @@ pub(crate) struct OpenRtbResponseConversion { pub delivery: AuctionDeliveryReport, } +/// Attach the consent/EID headers shared by every `/auction` response wire. +pub(crate) fn attach_auction_response_headers( + response: &mut Response, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result<(), Report> { + if ec_allowed { + response + .headers_mut() + .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); + } + + if let Some(ref eids) = auction_request.user.eids { + let (encoded, truncated) = encode_eids_header(eids)?; + let header_val = + HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { + message: "Failed to encode EIDs header value".to_string(), + })?; + response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); + if truncated { + response + .headers_mut() + .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); + } + } + + Ok(()) +} + #[allow( dead_code, reason = "pure coordinated-cutover contract is exercised directly until Task 19 wires endpoints" @@ -517,6 +546,18 @@ pub(crate) mod coordinated_cutover_v1 { && valid_render_source(&bid.render_source, publisher_origin) } + fn valid_browser_slot(slot: &BrowserAuctionSlotV1) -> bool { + valid_bounded_text(&slot.slot, 256) + && valid_bounded_text(&slot.gam_unit_path, 256) + && valid_bounded_text(&slot.div_id, 256) + && !slot.formats.is_empty() + && slot.formats.len() <= 64 + && slot.formats.iter().all(|[width, height]| { + valid_render_dimension(*width) && valid_render_dimension(*height) + }) + && valid_targeting(&slot.targeting) + } + fn validate_decision_set( decision_set: &AuctionDecisionSetV1, ) -> Result<(), Report> { @@ -566,6 +607,29 @@ pub(crate) mod coordinated_cutover_v1 { projection_contract_error("Browser auction projection version must be 1") ); validate_decision_set(&input.auction)?; + ensure!( + input.slots.len() <= MAX_BROWSER_AUCTION_RESULTS, + projection_contract_error("Browser auction slot count exceeds 256") + ); + if !input.slots.is_empty() { + ensure!( + input.slots.len() == input.auction.results.len(), + projection_contract_error( + "Browser auction slots must cover every decision or be empty for direct serialization" + ) + ); + let mut slot_ids = HashSet::with_capacity(input.slots.len()); + for (index, slot) in input.slots.iter().enumerate() { + ensure!( + valid_browser_slot(slot) + && slot_ids.insert(slot.slot.as_str()) + && input.auction.results[index].slot() == slot.slot, + projection_contract_error( + "Browser auction slots must be valid, unique, and follow decision order" + ) + ); + } + } ensure!( input.bids.len() <= MAX_BROWSER_AUCTION_RESULTS, projection_contract_error("Browser auction bid count exceeds 256") @@ -623,6 +687,7 @@ pub(crate) mod coordinated_cutover_v1 { auction_id: input.auction.auction_id, results: canonical_results, }, + slots: input.slots, bids: canonical_bids, }; let mut json = @@ -949,27 +1014,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( message: "Failed to build auction response".to_string(), })?; - // Signal consent status independently of whether EIDs were resolved. - if ec_allowed { - response - .headers_mut() - .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); - } - - // Attach EID response headers when consent-gated EIDs are available. - if let Some(ref eids) = auction_request.user.eids { - let (encoded, truncated) = encode_eids_header(eids)?; - let header_val = - HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { - message: "Failed to encode EIDs header value".to_string(), - })?; - response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); - if truncated { - response - .headers_mut() - .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); - } - } + attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; Ok(OpenRtbResponseConversion { response, delivery }) } @@ -2498,6 +2543,7 @@ mod convert_tests { auction_id: "auction-1".to_string(), results, }, + slots: Vec::new(), bids, } } @@ -2692,6 +2738,7 @@ mod convert_tests { reason: crate::auction::types::AuctionSlotFailureReason::IdentityGenerationFailed, }], }, + slots: Vec::new(), bids: Vec::new(), }, "https://publisher.example", diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 7be13d7d5..407ba4972 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -503,6 +503,22 @@ pub struct BrowserAuctionBidV1 { pub render_source: BidRenderSourceV1, } +/// Exact GAM placement metadata required to publish one server-projected slot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowserAuctionSlotV1 { + /// Exact server slot identity joined to one auction decision. + pub slot: String, + /// Fully rendered GAM ad-unit path for this navigation. + pub gam_unit_path: String, + /// Stable configured DOM id/prefix for responsive resolution. + pub div_id: String, + /// Accepted banner dimensions in configured order. + pub formats: Vec<[u32; 2]>, + /// Static publisher targeting applied before winner targeting. + pub targeting: BTreeMap, +} + /// Complete browser-facing version-1 auction projection. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct BrowserAuctionProjectionV1 { @@ -510,6 +526,8 @@ pub struct BrowserAuctionProjectionV1 { pub version: u8, /// Ordered decision set for every requested slot. pub auction: AuctionDecisionSetV1, + /// Ordered GAM placement definitions; empty only for direct `/auction` serialization. + pub slots: Vec, /// Winner bids in matching decision order. pub bids: Vec, } diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 8e70aa020..a58cf5561 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -269,9 +269,8 @@ mod tests { /// handler covers is the operator's decision, and silently carving holes in /// it would be worse than a documented constraint. Operators must scope /// handler patterns to the paths they mean (`^/_ts/admin`) — see the - /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps - /// affected deployments serving SPA ads until they do, but it disappears - /// with the alias in IABTechLab/trusted-server#970. + /// configuration guide. A broad pattern will block the canonical page-bids + /// endpoint; the hard-cutover client does not retry a compatibility alias. #[test] fn broad_handler_regex_also_covers_browser_facing_endpoints() { let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#); diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 9047a7db3..754ac5cb8 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -5,13 +5,8 @@ use std::cell::Cell; use std::io; use std::rc::Rc; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use lol_html::{ - EndTagHandler, Settings as RewriterSettings, element, - html_content::{ContentType, EndTag}, - text, -}; +use lol_html::{Settings as RewriterSettings, element, html_content::ContentType, text}; use crate::integrations::datadome::{DATADOME_INTEGRATION_ID, DataDomeClientTagSuppressed}; use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; @@ -20,11 +15,13 @@ use crate::integrations::{ IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationRegistry, IntegrationScriptContext, ScriptRewriteAction, }; -use crate::publisher::build_empty_bids_script; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; +const EMPTY_AUCTION_PROJECTION_JSON: &str = + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#; + /// Wraps [`HtmlRewriterAdapter`] with optional post-processing. /// /// When `post_processors` is empty (the common streaming path), chunks pass @@ -176,6 +173,8 @@ pub struct HtmlProcessorConfig { pub max_buffered_body_bytes: usize, /// Request-scoped conditional diagnostics delivery decision. pub gpt_diagnostics: Option, + /// Server-owned request-scoped render-trace overlay decision. + pub render_trace_overlay: bool, /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. pub suppress_datadome_client_side_tag: bool, } @@ -199,6 +198,7 @@ impl HtmlProcessorConfig { 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, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, } } @@ -228,6 +228,13 @@ impl HtmlProcessorConfig { self } + /// Attach the server-owned request-scoped render-trace overlay decision. + #[must_use] + pub fn with_render_trace_overlay(mut self, active: bool) -> Self { + self.render_trace_overlay = active; + self + } + /// Attach the request-scoped `DataDome` client-tag suppression decision. #[must_use] pub fn with_datadome_client_tag_suppression(mut self, suppress: bool) -> Self { @@ -314,12 +321,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso }); let injected_tsjs = Rc::new(Cell::new(false)); - let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); 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 render_trace_overlay = config.render_trace_overlay; let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -328,7 +334,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integrations = integration_registry.clone(); let patterns = patterns.clone(); let document_state = document_state.clone(); - let ad_slots_script = ad_slots_script.clone(); + let ad_bids_state = ad_bids_state.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { @@ -342,23 +348,74 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso { snippet.push_str(&cleanup_tag); } - // Inject ad slots script first so it appears before tsjs bundle. - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } let ctx = IntegrationHtmlContext { request_host: &patterns.request_host, request_scheme: &patterns.request_scheme, origin_host: &patterns.origin_host, document_state: &document_state, }; - // First inject integration-specific config (e.g., window.__tsjs_prebid) - // so it's available when the bundle's auto-init code reads it. + let immediate_ids = integrations.js_module_ids_immediate(); + let deferred_ids = integrations.js_module_ids_deferred(); + let diagnostics_active = gpt_diagnostics + .as_ref() + .is_some_and(GptDiagnosticsRequestDecision::active); + let mut manifest_ids = immediate_ids.clone(); + if diagnostics_active && !manifest_ids.contains(&"gpt_diagnostics") { + manifest_ids.push("gpt_diagnostics"); + } + manifest_ids.extend(deferred_ids.iter().copied()); + let state = ad_bids_state + .lock() + .expect("should lock boot projection state"); + let state_value = state.as_deref(); + let (debug_comment, projection_json) = match state_value { + Some(value) if value.starts_with("", "trailing-content ".repeat(3 * 1024)); let page = format!("hello{trailing_comment}"); let compressed = gzip_encode(page.as_bytes()); @@ -8993,6 +9013,7 @@ mod tests { )), price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let publisher_response = PublisherResponse::Stream { @@ -9021,10 +9042,11 @@ mod tests { let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); assert!( - html.contains("var b=JSON.parse("), - "should collect the held auction and inject bids. Got tail: {}", - &html[html.len().saturating_sub(200)..] + html.contains(r#""auctionId":"test-auction""#), + "should collect the exact projection before the compressed head. Got head: {}", + &html[..html.len().min(500)] ); + assert!(!html.contains(".bids=")); assert!( html.contains("trailing-content"), "should preserve content after the close-body tag" @@ -9061,6 +9083,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let mut output = Vec::new(); @@ -9112,6 +9135,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; @@ -9221,6 +9245,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; let mut output = Vec::new(); @@ -9279,6 +9304,7 @@ mod tests { dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), gpt_diagnostics: None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, }; @@ -11005,119 +11031,6 @@ mod tests { .expect("should return ok response") } - /// The deprecated `/__ts/page-bids` alias must be handled identically to - /// the canonical path — same status, same JSON body. - /// - /// The alias exists so pre-rename tsjs bundles keep getting ads on SPA - /// navigations. If the handler ever varied its output by request path - /// (slot matching reads the `path` *query parameter*, not the endpoint - /// path), those clients would silently get different results from the - /// ones on the canonical route. - #[tokio::test] - async fn deprecated_alias_response_matches_canonical_path() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - let canonical = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), - ) - .await; - let alias = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), - ) - .await; - - assert_eq!( - canonical.status(), - alias.status(), - "alias must return the same status as the canonical path" - ); - assert_eq!( - canonical.into_body().into_bytes(), - alias.into_body().into_bytes(), - "alias must return the same body as the canonical path" - ); - } - - /// Traffic on the deprecated alias must be measurable from edge access - /// logs, not just application logs: the removal precondition in - /// IABTechLab/trusted-server#970 is "no remaining traffic on the legacy - /// path", and operators who cannot read app logs need a response-side - /// marker to count. - #[tokio::test] - async fn deprecated_alias_response_is_marked_deprecated() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - let canonical = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_PATH, "/2024/01/my-article/"), - ) - .await; - let alias = run_page_bids_response( - &settings, - &orchestrator, - &article_slot(), - make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), - ) - .await; - - assert_eq!( - alias - .headers() - .get(header::LINK) - .and_then(|value| value.to_str().ok()), - Some( - "; rel=\"deprecation\"" - ), - "alias response should carry the RFC 9745 deprecation link relation" - ); - assert!( - !canonical.headers().contains_key(header::LINK), - "canonical path should not be marked deprecated" - ); - } - - /// A deployment without creative opportunities answers page-bids with a - /// 404, but its alias traffic still has to be counted — otherwise a - /// silent legacy signal on such a config reads as "no remaining - /// traffic" when evaluating IABTechLab/trusted-server#970. - #[tokio::test] - async fn deprecated_alias_is_marked_without_creative_opportunities() { - let settings = settings_without_co(); - assert!( - settings.creative_opportunities.is_none(), - "test settings should have no creative opportunities configured" - ); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - - let response = run_page_bids_response( - &settings, - &orchestrator, - &[], - make_page_bids_request_on(PAGE_BIDS_LEGACY_PATH, "/2024/01/my-article/"), - ) - .await; - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "should 404 when creative opportunities are not configured" - ); - assert!( - response.headers().contains_key(header::LINK), - "alias 404 should still be marked deprecated so it is countable" - ); - } - /// The cross-site gate runs before the not-configured 404, so a /// cross-site caller cannot probe whether a deployment has creative /// opportunities configured. @@ -11213,7 +11126,7 @@ mod tests { } #[tokio::test] - async fn empty_slots_file_returns_empty_slots_and_bids() { + async fn empty_slots_file_returns_an_exact_empty_projection() { // Spec §8 kill-switch: creative-opportunities.toml with zero slots disables // all server-side auction activity and injection. let settings = settings_with_co(); @@ -11222,26 +11135,19 @@ mod tests { let body = run_page_bids(&settings, &orchestrator, &[], req).await; + assert_eq!(body["version"], 1); + assert_eq!(body["auction"]["version"], 1); + assert_eq!(body["auction"]["results"], serde_json::json!([])); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "empty slots should produce zero injected slots" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "empty slots should produce zero bids" ); + assert_eq!(body["slots"], serde_json::json!([])); } #[tokio::test] - async fn bot_user_agent_returns_slots_but_no_bids() { + async fn bot_user_agent_returns_a_terminal_projection_without_bids() { // Crawlers should get slot definitions (so HTML structure is unchanged) // but the server must not burn SSP request quota running a real auction // for them. Same gate the publisher path applies. @@ -11258,25 +11164,22 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 1, - "bot request should still get slot definitions" + body["auction"]["results"][0], + serde_json::json!({ + "slot": "atf", + "outcome": "failed", + "reason": "slot_not_eligible" + }) ); assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "bot request must not run an auction (no SSP cost burned for crawlers)" ); } #[tokio::test] - async fn prefetch_request_returns_slots_but_no_bids() { + async fn prefetch_request_returns_a_terminal_projection_without_bids() { // Navigations triggered by Sec-Purpose=prefetch should not fire real // SSP auctions — the user has not yet visited the page. let settings = settings_with_co(); @@ -11287,19 +11190,9 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"][0]["reason"], "slot_not_eligible"); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 1, - "prefetch request should still get slot definitions" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "prefetch request must not run an auction" ); @@ -11332,7 +11225,9 @@ mod tests { set_test_header(&mut req, "sec-purpose", "prefetch"); let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; - let returned_slots = body["slots"].as_array().expect("slots should be array"); + let returned_slots = body["auction"]["results"] + .as_array() + .expect("results should be array"); assert_eq!( returned_slots.len(), @@ -11340,7 +11235,7 @@ mod tests { "should omit only the over-limit dynamic slot" ); assert_eq!( - returned_slots[0]["id"], "valid_static_sibling", + returned_slots[0]["slot"], "valid_static_sibling", "should retain the valid static sibling" ); } @@ -11355,19 +11250,9 @@ mod tests { let body = run_page_bids(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"], serde_json::json!([])); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "non-matching URL should produce zero injected slots" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "non-matching URL should produce zero bids" ); @@ -11417,7 +11302,7 @@ mod tests { } #[tokio::test] - async fn disabled_auction_returns_no_slots_or_bids() { + async fn disabled_auction_returns_exact_failed_decisions() { // [auction].enabled = false is a global kill switch: it must disable // the entire server-side ad stack, not just SSP calls. Returning slot // definitions would let the SPA hook assign `ts.adSlots` and call @@ -11431,26 +11316,16 @@ mod tests { let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"][0]["reason"], "auction_disabled"); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "disabled auction must not return slot definitions (kill switch stops the ad stack)" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "disabled auction must not produce bids" ); } #[tokio::test] - async fn consent_denied_returns_no_slots_or_bids() { + async fn consent_denied_returns_exact_failed_decisions() { // When consent denies the server-side auction (here: Jurisdiction // Unknown fails closed), the endpoint must return no slots so the SPA // hook does not create GPT slots client-side — matching the publisher @@ -11464,19 +11339,9 @@ mod tests { // Jurisdiction::Unknown (consent denied). let body = run_page_bids(&settings, &orchestrator, &slots, req).await; + assert_eq!(body["auction"]["results"][0]["reason"], "consent_denied"); assert_eq!( - body["slots"] - .as_array() - .expect("slots should be array") - .len(), - 0, - "consent denial must suppress slot definitions" - ); - assert_eq!( - body["bids"] - .as_object() - .expect("bids should be object") - .len(), + body["bids"].as_array().expect("bids should be array").len(), 0, "consent denial must produce no bids" ); diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 1d71a981d..7794f4f8d 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -37,6 +37,92 @@ pub fn tsjs_boot_manifest_v1(module_ids: &[&str]) -> Result { + /// Enabled integration bundles in their actual injection order. + pub module_ids: &'a [&'a str], + /// Canonical exact [`BrowserAuctionProjectionV1`](crate::auction::types::BrowserAuctionProjectionV1) + /// JSON produced by the auction projection boundary. + pub auction_projection_json: &'a str, + /// Exact creative integration boot configuration. + pub creative: CreativeBootConfigV1, + /// Whether the local render-trace overlay is active for this document. + pub render_trace_overlay: bool, + /// Whether request/session-scoped GPT diagnostics is active. + pub gpt_diagnostics_active: bool, +} + +/// Serialize the sole pre-core `TsjsBootV1` assignment and bids-ready mark. +/// +/// The returned inline script keeps the publisher-created `window.tsjs` object, +/// writes only the exact boot transport, and escapes every HTML-significant JSON +/// character before insertion into a script element. +/// +/// # Errors +/// +/// Returns an error for an invalid manifest, non-object projection JSON, or a +/// creative/diagnostics enabled bit that disagrees with manifest membership. +pub fn tsjs_boot_script_v1( + config: TsjsBootScriptConfigV1<'_>, +) -> Result> { + let manifest = tsjs_boot_manifest_v1(config.module_ids)?; + let projection = serde_json::from_str::(config.auction_projection_json) + .map_err(|_| boot_manifest_error("auction projection is not valid JSON"))?; + if !projection.is_object() { + return Err(boot_manifest_error("auction projection must be an object")); + } + + let creative_in_manifest = config.module_ids.contains(&"creative"); + if creative_in_manifest != config.creative.enabled + || (!config.creative.enabled + && (config.creative.click_guard || config.creative.render_guard)) + { + return Err(boot_manifest_error( + "creative boot bits disagree with manifest membership", + )); + } + let diagnostics_in_manifest = config.module_ids.contains(&"gpt_diagnostics"); + if diagnostics_in_manifest != config.gpt_diagnostics_active { + return Err(boot_manifest_error( + "GPT diagnostics boot bit disagrees with manifest membership", + )); + } + + let manifest = escape_json_for_inline_script(&manifest); + let projection = escape_json_for_inline_script(config.auction_projection_json); + Ok(format!( + "", + release_id(), + manifest, + projection, + config.creative.enabled, + config.creative.click_guard, + config.creative.render_guard, + config.render_trace_overlay, + config.gpt_diagnostics_active, + )) +} + +fn escape_json_for_inline_script(json: &str) -> String { + json.replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") +} + fn valid_integration_id(id: &str) -> bool { let bytes = id.as_bytes(); !bytes.is_empty() @@ -189,6 +275,74 @@ mod tests { } } + #[test] + fn boot_script_serializes_the_exact_hard_cutover_transport_and_mark() { + let script = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { + module_ids: &["creative", "gpt", "gpt_diagnostics"], + auction_projection_json: + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#, + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: true, + render_guard: false, + }, + render_trace_overlay: true, + gpt_diagnostics_active: true, + }) + .expect("should serialize boot transport"); + + assert!(script.starts_with("")); + } + + #[test] + fn boot_script_rejects_manifest_diagnostics_mismatch_and_escapes_projection_markup() { + let mismatched = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { + module_ids: &["creative"], + auction_projection_json: r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#, + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: true, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: true, + }); + assert!( + mismatched.is_err(), + "should reject an active diagnostics bit without its module" + ); + + let script = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { + module_ids: &["creative"], + auction_projection_json: + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[],"probe":""); + + assert!(!inner.contains('<')); + assert!(!inner.contains('>')); + assert!(!inner.contains('&')); + assert!(inner.contains(r#"\u003c/ScRiPt\u003e\u003cscript\u003e\u0026\u2028"#)); + } + #[test] fn tsjs_script_src_formats_unified_bundle_url_with_hash() { let src = tsjs_script_src(&["creative"]); diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index d28d4c264..a4225afdd 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -21,6 +21,7 @@ import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:z import { fileURLToPath } from 'node:url'; import { build } from 'vite'; +import { discoverIntegrationModules } from './scripts/integration-inventory-v1.mjs'; import { computeReleaseId, RELEASE_SENTINEL, stampRelease } from './scripts/release-v1.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -66,17 +67,7 @@ fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); // Discover integration modules: directories in src/integrations/ with index.ts -const integrationModules = fs.existsSync(integrationsDir) - ? fs - .readdirSync(integrationsDir) - .filter((name) => { - const fullPath = path.join(integrationsDir, name); - return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) - ); - }) - .sort() - : []; +const integrationModules = discoverIntegrationModules(integrationsDir); console.log('[build-all] Discovered integrations:', integrationModules); @@ -89,6 +80,7 @@ async function buildModule(name, entryPath, outFile = `tsjs-${name}.js`) { root: __dirname, define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(integrationModules), }, build: { emptyOutDir: false, @@ -115,7 +107,7 @@ async function buildModule(name, entryPath, outFile = `tsjs-${name}.js`) { } // Build core first (synchronously), then all integrations in parallel -await buildModule('core', path.join(srcDir, 'core', 'index.ts')); +await buildModule('core', path.join(srcDir, 'composition', 'index.ts')); await Promise.all( integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index a540046b8..af6b1fe44 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -39,6 +39,25 @@ export interface GoogletagReplacementCommitAdmission { rollback(): void; } +/** Outcome of one adapter-owned initial GPT slot-definition transaction. */ +export type GoogletagDefinitionResult = Readonly< + { status: 'discarded' } | { status: 'defined'; slot: object } +>; + +/** Failure to define or synchronously retire one adapter-owned GPT slot. */ +export class GoogletagDefinitionError extends Error { + public readonly code = 'gpt_definition_failed'; + public readonly cause: unknown; + public readonly orphanedSlot: object | undefined; + + public constructor(orphanedSlot?: object, cause?: unknown) { + super('gpt_definition_failed'); + this.name = 'GoogletagDefinitionError'; + this.orphanedSlot = orphanedSlot; + this.cause = cause; + } +} + /** Successful outcome of one GPT destroy/redefine transaction. */ export type GoogletagReplacementResult = Readonly< { status: 'destroyed' } | { status: 'replaced'; slot: object } @@ -153,6 +172,11 @@ export interface GoogletagFacade { adUnitPath?(slot: object): unknown; bindingToken(): object; clearTargeting(slot: object, key?: string): unknown; + transactionalDefine( + definition: GoogletagReplacementDefinition, + isGenerationCurrent: () => boolean, + prepareCommit: (slot: object) => GoogletagReplacementCommitAdmission + ): GoogletagDefinitionResult; display(slot: string | object): unknown; getTargeting(slot: object, key: string): readonly string[]; observeTargeting( @@ -166,6 +190,7 @@ export interface GoogletagFacade { pubadsReady: boolean; }>; setTargeting(slot: object, key: string, value: string | readonly string[]): unknown; + slotElementId?(slot: object): unknown; slots(): readonly object[]; subscribe(eventType: string, listener: (event: unknown) => void): () => void; transactionalReplace( @@ -553,6 +578,92 @@ function createFacade( bindingToken: (): object => bindingToken, clearTargeting: (slot: object, key?: string): unknown => call(slot, 'clearTargeting', key === undefined ? [] : [key]), + transactionalDefine: ( + definition: GoogletagReplacementDefinition, + isGenerationCurrent: () => boolean, + prepareCommit: (slot: object) => GoogletagReplacementCommitAdmission + ): GoogletagDefinitionResult => { + if ( + typeof isGenerationCurrent !== 'function' || + typeof prepareCommit !== 'function' || + !isOperationCurrent() + ) { + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + const destroy = (slot: object): boolean => { + try { + return call(binding.binding, 'destroySlots', [[slot]]) === true; + } catch { + return false; + } + }; + const discarded = Object.freeze({ status: 'discarded' as const }); + let candidate: object | undefined; + let admission: GoogletagReplacementCommitAdmission | undefined; + let commitAttempted = false; + const discard = (slot: object, cause?: unknown): GoogletagDefinitionResult => { + if (!destroy(slot)) throw new GoogletagDefinitionError(slot, cause); + return discarded; + }; + try { + if (!isGenerationCurrent() || !isOperationCurrent()) return discarded; + const defined = call(binding.binding, 'defineSlot', [ + definition.adUnitPath, + definition.sizes, + definition.elementId, + ]); + if ((typeof defined !== 'object' || defined === null) && typeof defined !== 'function') { + throw new GoogletagDefinitionError(); + } + candidate = defined as object; + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = candidate; + candidate = undefined; + return discard(stale); + } + admission = prepareCommit(candidate); + if ( + !admission || + typeof admission.commit !== 'function' || + typeof admission.rollback !== 'function' + ) { + throw new GoogletagDefinitionError(); + } + call(candidate, 'addService', [service()]); + if (!isGenerationCurrent() || !isOperationCurrent()) { + const stale = candidate; + candidate = undefined; + return discard(stale); + } + commitAttempted = true; + if (!admission.commit()) throw new GoogletagDefinitionError(); + if (!isGenerationCurrent() || !isOperationCurrent()) { + try { + admission.rollback(); + } finally { + commitAttempted = false; + } + const stale = candidate; + candidate = undefined; + return discard(stale); + } + return Object.freeze({ status: 'defined' as const, slot: candidate }); + } catch (error) { + if (commitAttempted) { + try { + admission?.rollback(); + } catch { + // Candidate retirement remains mandatory after bookkeeping rollback failure. + } + } + if (candidate) { + const failed = candidate; + if (!destroy(failed)) throw new GoogletagDefinitionError(failed, error); + } + if (error instanceof GoogletagDefinitionError) throw error; + throw new GoogletagDefinitionError(undefined, error); + } + }, display: (slot: string | object): unknown => { const display = member(binding.binding, 'display'); if (!isOperationCurrent()) throw new GoogletagAdapterError('external_artifact_incompatible'); @@ -691,6 +802,7 @@ function createFacade( }, setTargeting: (slot: object, key: string, value: string | readonly string[]): unknown => call(slot, 'setTargeting', [key, Array.isArray(value) ? [...value] : value]), + slotElementId: (slot: object): unknown => call(slot, 'getSlotElementId', []), slots: (): readonly object[] => { const currentSlots = call(service(), 'getSlots', []); if ( diff --git a/crates/trusted-server-js/lib/src/composition/browser.ts b/crates/trusted-server-js/lib/src/composition/browser.ts index 6aa3df2bd..cd474c1b1 100644 --- a/crates/trusted-server-js/lib/src/composition/browser.ts +++ b/crates/trusted-server-js/lib/src/composition/browser.ts @@ -24,6 +24,7 @@ import { parseTrustedServerAuctionResponseV1 } from '../core/auction'; import type { BootManifestV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, CreativeBootV1, DiagnosticsBootV1, } from '../core/types'; @@ -220,6 +221,7 @@ export interface TestBrowserRuntimeCompositionOptions extends BrowserComposition readonly createIdentityIssuerForTest?: NavigationIdentityIssuerFactory; readonly admittedProgrammaticSlotsForTest?: readonly string[]; readonly gptStartupForTest?: (config: unknown) => void; + readonly pageBidsFetcherForTest?: PageBidsFetcher; readonly prebidStartupForTest?: (config: unknown) => void; readonly pucSchedulerForTest?: PucBridgeOptions['scheduler']; } @@ -235,18 +237,241 @@ interface AcceptedBrowserBoot { } interface PreparedBrowserServices { - readonly createAttempt: (owner: RenderAttemptScope) => ReturnType; + readonly createAttempt: ( + owner: RenderAttemptScope, + parentAttemptId?: string + ) => ReturnType; readonly publisherOrigin: string; + readonly renderProjectedFallback: (attempt: RenderAttempt) => boolean; readonly rendererUrl: string; readonly resolveCacheAdm: NonNullable; readonly services: Readonly>; } -function projectionSlots(projection: object): readonly string[] { - const accepted = projection as { - readonly auction: { readonly results: readonly { readonly slot: string }[] }; +interface PageBidsResponse { + readonly ok: boolean; + readonly json: () => Promise; +} + +type PageBidsFetcher = ( + input: string, + init: Readonly<{ + credentials: 'include'; + headers: Readonly<{ 'X-TSJS-Page-Bids': '1' }>; + signal: AbortSignal; + }> +) => PromiseLike; + +interface PageBidsNavigationLifecycle { + readonly activate: () => () => void; + readonly start: () => void; +} + +type GptProjectionPublisher = ( + navigation: NonNullable, + projection: Readonly, + requestClass: string +) => void; + +const noopGptProjectionPublisher: GptProjectionPublisher = () => undefined; + +function resolveProjectedSlotElement( + placement: Readonly +): HTMLElement | undefined { + try { + if (typeof document === 'undefined') return undefined; + const exact = document.getElementById(placement.divId); + if (exact instanceof HTMLElement) return exact; + const prefixMatches = [...document.querySelectorAll('[id]')].filter( + (element) => element.id.startsWith(placement.divId) && !element.id.endsWith('-container') + ); + if (prefixMatches.length === 1) return prefixMatches[0]; + const visible = prefixMatches.filter((element) => isEffectivelyVisible(element)); + if (visible.length === 1) return visible[0]; + const active = visible.filter((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + return active.length === 1 ? active[0] : undefined; + } catch { + return undefined; + } +} + +function currentBrowserPath(): string | undefined { + try { + return `${window.location.pathname}${window.location.search}`; + } catch { + return undefined; + } +} + +function restoreHistoryMethod( + name: 'pushState' | 'replaceState', + previous: PropertyDescriptor | undefined, + installed: History['pushState'] +): void { + try { + const current = Object.getOwnPropertyDescriptor(window.history, name); + if (!current || !('value' in current) || current.value !== installed) return; + if (previous) Object.defineProperty(window.history, name, previous); + else Reflect.deleteProperty(window.history, name); + } catch { + // A publisher replacement remains authoritative; the disposed wrapper is inert. + } +} + +/** Own the canonical page-bids fetch and one replacement session per SPA navigation. */ +function createPageBidsNavigationLifecycle(options: { + readonly fetcher?: PageBidsFetcher; + readonly onProjectionCommitted?: ( + navigation: NonNullable, + projection: Readonly + ) => void; + readonly runtimeSession: () => RuntimeSession | undefined; + readonly services: () => Readonly | undefined; + readonly projectionParser: () => ((candidate: unknown) => object | undefined) | undefined; +}): PageBidsNavigationLifecycle { + let active = false; + let disposed = false; + let started = false; + let appliedPath: string | undefined; + let currentPath: string | undefined; + let release: (() => void) | undefined; + + const rollBackPath = ( + path: string, + navigation?: NonNullable + ): void => { + if (currentPath !== path || (navigation && !navigation.isCurrent())) return; + currentPath = appliedPath; + }; + + const requestProjection = async (path: string): Promise => { + const session = options.runtimeSession(); + const replacement = session?.replaceNavigation(); + if (!replacement?.ok) { + rollBackPath(path); + return; + } + const navigation = replacement.value; + const services = options.services(); + const parseProjection = options.projectionParser(); + if (!services || !parseProjection) { + rollBackPath(path, navigation); + return; + } + const controller = createPageBidsController({ + navigation, + parseProjection, + slotRegistry: services.slots.projectionRegistry(navigation), + }); + const fetcher = options.fetcher ?? globalThis.fetch; + if (typeof fetcher !== 'function') { + rollBackPath(path, navigation); + return; + } + let committed = false; + try { + const response = await fetcher(`/_ts/page-bids?path=${encodeURIComponent(path)}`, { + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: navigation.signal, + }); + if (!navigation.isCurrent()) return; + if (!response.ok) { + rollBackPath(path, navigation); + return; + } + const candidate = await response.json(); + if (!navigation.isCurrent()) return; + const result = controller.commit(candidate); + if (result.status === 'committed') { + committed = true; + appliedPath = path; + const projection = navigation.currentAuctionProjection; + if (projection) options.onProjectionCommitted?.(navigation, projection); + } + if (result.status === 'rejected' && result.reason !== 'stale') { + rollBackPath(path, navigation); + log.warn('page-bids: rejected navigation projection', result.reason); + } + } catch (error) { + if (!navigation.signal.aborted) { + if (!committed) rollBackPath(path, navigation); + log.warn('page-bids: projection request failed', error); + } + } + }; + + const navigateIfChanged = (): void => { + if (!active || !started || disposed) return; + const path = currentBrowserPath(); + if (path === undefined || path === currentPath) return; + currentPath = path; + void requestProjection(path); }; - return Object.freeze(accepted.auction.results.map(({ slot }) => slot)); + + return Object.freeze({ + activate: (): (() => void) => { + if (active || disposed) throw new Error('Page-bids navigation owner is unavailable'); + const history = window.history; + const previousPushState = Object.getOwnPropertyDescriptor(history, 'pushState'); + const previousReplaceState = Object.getOwnPropertyDescriptor(history, 'replaceState'); + const pushState = history.pushState; + const replaceState = history.replaceState; + const wrap = (original: History['pushState']): History['pushState'] => + function wrappedHistoryState( + this: History, + data: unknown, + unused: string, + url?: string | URL | null + ): void { + Reflect.apply(original, this, [data, unused, url]); + navigateIfChanged(); + }; + const wrappedPushState = wrap(pushState); + const wrappedReplaceState = wrap(replaceState); + const onPopState = (): void => navigateIfChanged(); + try { + Object.defineProperty(history, 'pushState', { + configurable: true, + enumerable: previousPushState?.enumerable ?? false, + value: wrappedPushState, + writable: true, + }); + Object.defineProperty(history, 'replaceState', { + configurable: true, + enumerable: previousReplaceState?.enumerable ?? false, + value: wrappedReplaceState, + writable: true, + }); + window.addEventListener('popstate', onPopState); + active = true; + } catch (error) { + restoreHistoryMethod('replaceState', previousReplaceState, wrappedReplaceState); + restoreHistoryMethod('pushState', previousPushState, wrappedPushState); + throw error; + } + let released = false; + release = (): void => { + if (released) return; + released = true; + disposed = true; + active = false; + window.removeEventListener('popstate', onPopState); + restoreHistoryMethod('replaceState', previousReplaceState, wrappedReplaceState); + restoreHistoryMethod('pushState', previousPushState, wrappedPushState); + }; + return release; + }, + start: (): void => { + if (!active || disposed) return; + currentPath = currentBrowserPath(); + appliedPath = currentPath; + started = true; + }, + }); } interface ComposedPrebidRefreshConfig { @@ -426,6 +651,9 @@ export function createBrowserRuntimeComposition( const composition = createBrowserComposition(compositionOptions); const providedBindings = runtimeOptions.getBindings; let browserServices: Readonly | undefined; + let gptProjectionPublisher = noopGptProjectionPublisher; + let projectionParser: ((candidate: unknown) => object | undefined) | undefined; + let runtimeSession: RuntimeSession | undefined; let creativeBoot: Readonly | undefined; let diagnosticsBoot: Readonly | undefined; let diagnosticsBus: DiagnosticsBus | undefined; @@ -570,6 +798,20 @@ export function createBrowserRuntimeComposition( start: compositionOptions.creativeStartupForTest ?? defaultCreativeRuntime.start, }); const startGpt = compositionOptions.gptStartupForTest ?? (() => undefined); + const pageBidsNavigation = createPageBidsNavigationLifecycle({ + ...(compositionOptions.pageBidsFetcherForTest + ? { fetcher: compositionOptions.pageBidsFetcherForTest } + : {}), + onProjectionCommitted: (navigation, projection) => + gptProjectionPublisher( + navigation, + projection as Readonly, + 'page-bids' + ), + projectionParser: () => projectionParser, + runtimeSession: () => runtimeSession, + services: () => browserServices, + }); const gptRuntime = createGptStartup({ googletag: composition.adapters.googletag, slots: () => { @@ -580,10 +822,34 @@ export function createBrowserRuntimeComposition( start: startGpt, }); const gptIntegrationRuntime = Object.freeze({ - activate: gptRuntime.activate, - start: gptRuntime.start, + activate: (): (() => void) => { + const releaseGpt = gptRuntime.activate(); + let releaseNavigation: (() => void) | undefined; + try { + releaseNavigation = pageBidsNavigation.activate(); + } catch (error) { + releaseGpt(); + throw error; + } + return (): void => { + releaseNavigation?.(); + releaseGpt(); + }; + }, + start: (config: unknown): void => { + gptRuntime.start(config); + pageBidsNavigation.start(); + const navigation = runtimeSession?.currentNavigation; + const projection = navigation?.currentAuctionProjection; + if (navigation && projection) { + gptProjectionPublisher( + navigation, + projection as Readonly, + 'initial' + ); + } + }, }); - let runtimeSession: RuntimeSession | undefined; let prebidCoordinator: PrebidSelectionCoordinator | undefined; let prebidRefreshConfig = EMPTY_PREBID_REFRESH_CONFIG; const startPrebid = compositionOptions.prebidStartupForTest ?? (() => undefined); @@ -736,7 +1002,168 @@ export function createBrowserRuntimeComposition( target: window as typeof window & { testlight?: { que?: unknown[] } }, }); let auctionBatchService: AuctionBatchService | undefined; - let projectionParser: ((candidate: unknown) => object | undefined) | undefined; + const publishProjectionThroughGpt = async ( + navigation: NonNullable, + projection: Readonly, + requestClass: string + ): Promise => { + const prepared = preparedBrowserServices; + const services = browserServices; + if (!prepared || !services || !navigation.isCurrent() || projection.slots.length === 0) return; + const physicalBySlot = new Map< + string, + Readonly<{ operation: 'display' | 'refresh'; slot: object }> + >(); + const operation = composition.adapters.googletag.run( + (gpt) => { + for (let index = 0; index < projection.slots.length; index += 1) { + const placement = projection.slots[index]; + if (!placement || !navigation.isCurrent()) break; + const element = resolveProjectedSlotElement(placement); + if (!element) continue; + const definition = Object.freeze({ + adUnitPath: placement.gamUnitPath, + elementId: element.id, + sizes: placement.formats, + }); + const existing = gpt.slots().filter((slot) => gpt.slotElementId?.(slot) === element.id); + if (existing.length > 1) continue; + const publisherSlot = existing[0]; + if (publisherSlot) { + const adopted = services.slots.adoptGptSlot(navigation.generation, placement.slot, { + definition, + elementIdPrefix: placement.divId, + ownership: 'publisher', + slot: publisherSlot, + }); + if (adopted.ok) { + physicalBySlot.set( + placement.slot, + Object.freeze({ operation: 'refresh', slot: publisherSlot }) + ); + } + continue; + } + const defined = gpt.transactionalDefine( + definition, + () => navigation.isCurrent(), + (candidate) => { + let committed = false; + return Object.freeze({ + commit: (): boolean => { + const adopted = services.slots.adoptGptSlot( + navigation.generation, + placement.slot, + { + definition, + elementIdPrefix: placement.divId, + ownership: 'trusted_server', + slot: candidate, + } + ); + committed = adopted.ok; + return committed; + }, + rollback: (): void => { + if (!committed) return; + committed = false; + services.slots.recordPublisherDestruction(candidate); + }, + }); + } + ); + if (defined.status === 'defined') { + physicalBySlot.set( + placement.slot, + Object.freeze({ operation: 'display', slot: defined.slot }) + ); + } + } + }, + { signal: navigation.signal } + ); + try { + await operation.result; + } catch (error) { + if (!navigation.signal.aborted) log.warn('GPT projection: slot binding failed', error); + } + if (!navigation.isCurrent()) return; + const batch = navigation.createAuctionBatch(`gpt:${projection.auction.auctionId}`); + if (!batch) return; + let winnerIndex = 0; + for (let index = 0; index < projection.auction.results.length; index += 1) { + const decision = projection.auction.results[index]; + const placement = projection.slots[index]; + if (!decision || !placement || decision.outcome !== 'winner') continue; + const bid = projection.bids[winnerIndex]; + winnerIndex += 1; + if (!bid || !navigation.isCurrent()) continue; + const owner = batch.createRenderAttempt(decision.slot); + if (!owner.ok) continue; + const created = prepared.createAttempt(owner.value); + if (!created.ok) continue; + const binding = physicalBySlot.get(decision.slot); + if (!binding) { + created.value.fail('slot_unresolved'); + continue; + } + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: created.value.id, + slot: created.value.slot, + navigationGeneration: created.value.navigationGeneration, + dispose: () => undefined, + }); + const published = await publishGptWinner({ + artifact, + attempt: created.value, + bid, + googletag: composition.adapters.googletag, + navigation, + operation: binding.operation, + owner: owner.value, + placement, + pucBridge: services.pucBridge, + requestClass, + reservations: services.reservations, + slot: binding.slot, + slots: services.slots, + targeting: services.targeting, + createFallback: (parentAttemptId) => { + const fallbackOwner = batch.createRenderAttempt(decision.slot); + if (!fallbackOwner.ok) { + return Object.freeze({ + ok: false as const, + reason: + fallbackOwner.reason === 'identity_generation_failed' + ? ('identity_generation_failed' as const) + : fallbackOwner.reason === 'stale_owner' + ? ('stale_owner' as const) + : ('invalid_attempt' as const), + }); + } + const fallback = prepared.createAttempt(fallbackOwner.value, parentAttemptId); + if (!fallback.ok) return fallback; + if ( + !fallback.value.admitDirectWinner( + bid.renderSource, + Object.freeze({ selectedCpm: bid.cpm }) + ) + ) { + fallback.value.fail('winner_not_renderable'); + return fallback; + } + if (!prepared.renderProjectedFallback(fallback.value)) { + fallback.value.fail('winner_not_renderable'); + } + return fallback; + }, + }); + if (!published.ok && navigation.isCurrent()) { + log.warn('GPT projection: winner publication failed', published.reason); + } + } + }; const frozenSlotResult = (result: Record): Readonly> => Object.freeze(result); const combineRequestResults = ( @@ -1109,10 +1536,11 @@ export function createBrowserRuntimeComposition( } }; const fetchAuction = compositionOptions.auctionFetcherForTest ?? globalThis.fetch; - const createOwnedAttempt = (owner: RenderAttemptScope) => + const createOwnedAttempt = (owner: RenderAttemptScope, parentAttemptId?: string) => createRenderAttempt({ artifacts, owner, + ...(parentAttemptId === undefined ? {} : { parentAttemptId }), prepareRenderSource: (candidate) => { const source = parseBidRenderSourceV1(candidate, cachePolicy); return source ? Object.freeze(source) : undefined; @@ -1120,6 +1548,19 @@ export function createBrowserRuntimeComposition( publishDiagnostics: preparedDiagnosticsBus.publish, reservations: reservationService, }); + const renderProjectedFallback = (attempt: RenderAttempt): boolean => { + const record = slotService.resolveRegisteredSlot(attempt.slot); + const container = record && resolveDirectContainer(record); + if (!container) { + attempt.fail('slot_unresolved'); + return false; + } + if (attempt.renderSource?.type === 'aps') return renderDirectAps(attempt, container); + if (attempt.renderSource?.type === 'adm') return renderDirectAdm(attempt, container); + if (attempt.renderSource?.type === 'cache') return renderDirectCache(attempt, container); + attempt.fail('winner_not_renderable'); + return false; + }; const batchCoordinator = createAuctionBatchService({ ...(cachePolicy ? { cachePolicy } : {}), createAttempt: createOwnedAttempt, @@ -1128,19 +1569,7 @@ export function createBrowserRuntimeComposition( return fetchAuction(input, init); }, parseResponse: parseTrustedServerAuctionResponseV1, - renderWinner: (attempt) => { - const record = slotService.resolveRegisteredSlot(attempt.slot); - const container = record && resolveDirectContainer(record); - if (!container) { - attempt.fail('slot_unresolved'); - return false; - } - if (attempt.renderSource?.type === 'aps') return renderDirectAps(attempt, container); - if (attempt.renderSource?.type === 'adm') return renderDirectAdm(attempt, container); - if (attempt.renderSource?.type === 'cache') return renderDirectCache(attempt, container); - attempt.fail('winner_not_renderable'); - return false; - }, + renderWinner: renderProjectedFallback, }); const services = Object.freeze({ artifacts, @@ -1156,6 +1585,7 @@ export function createBrowserRuntimeComposition( preparedBrowserServices = Object.freeze({ createAttempt: createOwnedAttempt, publisherOrigin, + renderProjectedFallback, rendererUrl, resolveCacheAdm, services, @@ -1217,9 +1647,11 @@ export function createBrowserRuntimeComposition( const navigation = session.startInitialNavigation(initialProjection); if (!navigation.ok) throw new Error(navigation.reason); + const acceptedInitialProjection = initialProjection as Readonly; const initialRegistrations = [ - ...projectionSlots(initialProjection).map((registeredSlotId) => ({ - registeredSlotId, + ...acceptedInitialProjection.slots.map((placement) => ({ + domAliases: Object.freeze([placement.divId]), + registeredSlotId: placement.slot, source: 'server' as const, })), ...(compositionOptions.admittedProgrammaticSlotsForTest ?? []).map((registeredSlotId) => ({ @@ -1271,6 +1703,14 @@ export function createBrowserRuntimeComposition( }); context.onDispose(() => pucBridge.dispose()); browserServices = Object.freeze({ ...prepared.services, pucBridge }); + gptProjectionPublisher = (navigation, projection, requestClass): void => { + void publishProjectionThroughGpt(navigation, projection, requestClass).catch((error) => { + if (navigation.isCurrent()) log.warn('GPT projection: coordinator failed', error); + }); + }; + context.onDispose(() => { + gptProjectionPublisher = noopGptProjectionPublisher; + }); const coordinator = createPrebidSelectionCoordinator({ activateAttempt: ({ attempt, owner, preparedBid }): boolean => { const artifact = Object.freeze({ diff --git a/crates/trusted-server-js/lib/src/composition/index.ts b/crates/trusted-server-js/lib/src/composition/index.ts new file mode 100644 index 000000000..4e079851d --- /dev/null +++ b/crates/trusted-server-js/lib/src/composition/index.ts @@ -0,0 +1,7 @@ +import { startProductionRuntime } from '../core/index'; + +import { createBrowserRuntimeComposition } from './browser'; + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + startProductionRuntime(createBrowserRuntimeComposition); +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index 0fac2fd01..5757e3a98 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -244,6 +244,8 @@ export function parseTrustedServerAuctionResponseV1( const canonicalProjection: BrowserAuctionProjectionV1 = { version: 1, auction, + // Direct `/auction` units are programmatic DOM placements, not GAM slots. + slots: [], bids: canonicalBids, }; if (jsonUtf8ByteLength(canonicalProjection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { diff --git a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts index 1c29574d2..8e0254fab 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts @@ -6,6 +6,7 @@ import type { BidRenderSourceV1, BrowserAuctionBidV1, BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, CacheFetchPolicyV1, CacheRenderSourceV1, SlotAuctionDecisionV1, @@ -17,6 +18,7 @@ export const MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024; export const MAX_AUCTION_RESULTS = 256; const MAX_TARGETING_ENTRIES = 32; +const MAX_SLOT_FORMATS = 64; const MAX_ADM_BYTES = 512 * 1024; const MAX_URL_BYTES = 4096; const reflectApplyIntrinsic = Reflect.apply; @@ -571,6 +573,37 @@ function parseBrowserBid( }; } +function parseBrowserSlot(value: unknown): BrowserAuctionSlotV1 | undefined { + const slot = ownDataObject(value, ['slot', 'gamUnitPath', 'divId', 'formats', 'targeting']); + if ( + !slot || + !validBoundedString(slot.slot, 256) || + !validBoundedString(slot.gamUnitPath, 256) || + !validBoundedString(slot.divId, 256) + ) { + return undefined; + } + const rawFormats = ownDataArray(slot.formats, MAX_SLOT_FORMATS); + if (!rawFormats || rawFormats.length === 0) return undefined; + const formats: Array = []; + for (let index = 0; index < rawFormats.length; index += 1) { + const pair = ownDataArray(rawFormats[index], 2); + if (!pair || pair.length !== 2 || !validDimension(pair[0]) || !validDimension(pair[1])) { + return undefined; + } + formats.push([pair[0], pair[1]]); + } + const targeting = parseTargeting(slot.targeting); + if (!targeting) return undefined; + return { + slot: slot.slot, + gamUnitPath: slot.gamUnitPath, + divId: slot.divId, + formats, + targeting, + }; +} + /** Validate, canonicalize, and deep-copy a complete browser auction projection. */ export function parseBrowserAuctionProjectionV1( value: unknown, @@ -580,11 +613,24 @@ export function parseBrowserAuctionProjectionV1( const cachePolicy = cachePolicyValue === undefined ? undefined : parseCacheFetchPolicyV1(cachePolicyValue); if (cachePolicyValue !== undefined && !cachePolicy) return undefined; - const record = ownDataObject(value, ['version', 'auction', 'bids']); + const record = ownDataObject(value, ['version', 'auction', 'slots', 'bids']); if (!record || record.version !== 1) return undefined; const auction = parseAuctionDecisionSetV1(record.auction); + const rawSlots = ownDataArray(record.slots, MAX_AUCTION_RESULTS); const rawBids = ownDataArray(record.bids, MAX_AUCTION_RESULTS); - if (!auction || !rawBids) return undefined; + if (!auction || !rawSlots || !rawBids || rawSlots.length !== auction.results.length) { + return undefined; + } + const slots: BrowserAuctionSlotV1[] = []; + const slotIds = new Set(); + for (let index = 0; index < rawSlots.length; index += 1) { + const slot = parseBrowserSlot(rawSlots[index]); + if (!slot || slotIds.has(slot.slot) || slot.slot !== auction.results[index]?.slot) { + return undefined; + } + slotIds.add(slot.slot); + slots.push(slot); + } const bids: BrowserAuctionBidV1[] = []; const candidateIds = new Set(); const reservationIds = new Set(); @@ -613,7 +659,7 @@ export function parseBrowserAuctionProjectionV1( } if (winnerIndex !== bids.length) return undefined; - const projection: BrowserAuctionProjectionV1 = { version: 1, auction, bids }; + const projection: BrowserAuctionProjectionV1 = { version: 1, auction, slots, bids }; if (jsonUtf8ByteLength(projection) > MAX_BROWSER_AUCTION_PROJECTION_BYTES) { return undefined; } diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 807292008..93ef0a0b6 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -1,74 +1,210 @@ -// Public tsjs core bundle: sets up the global API, queue, and default methods. +// Sole production bootstrap for the resilient TSJS runtime. export type { + AddAdUnitsResult, AdUnit, GptDiagnosticsApi, GptDiagnosticsExportV1, GptDiagnosticsRequestCycle, - LegacyTsjsApi, + ProgrammaticAdUnit, + RequestAdsOptions, + RequestAdsResult, + TsjsApi, + TsjsBootV1, + TsjsDiagnostics, } from './types'; -// Erased coordinated-cutover types only. Production ownership remains below until Task 19. export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; -import type { LegacyTsjsApi } from './types'; -import { addAdUnits } from './registry'; -import { renderAdUnit, renderAllAdUnits } from './render'; -import { log } from './log'; -import { setConfig, getConfig } from './config'; -import { requestAds } from './request'; -import { installQueue } from './queue'; -const VERSION = '0.1.0'; +import type { Runtime, RuntimeOptions } from '../kernel/runtime'; -const w: Window & { tsjs?: LegacyTsjsApi } = - ((globalThis as unknown as { window?: Window }).window as Window & { - tsjs?: LegacyTsjsApi; - }) || ({} as Window & { tsjs?: LegacyTsjsApi }); +import { EMBEDDED_INTEGRATION_IDS, EMBEDDED_RELEASE_ID } from './release'; -// Collect existing tsjs queued fns before we overwrite -const pending: Array<() => void> = Array.isArray(w.tsjs?.que) ? [...w.tsjs.que] : []; +const KNOWN_INTEGRATIONS = new Set(EMBEDDED_INTEGRATION_IDS); +const MAX_CONFIG_DEPTH = 16; +const MAX_CONFIG_NODES = 512; +const MAX_CONFIG_MEMBERS = 256; +const INVALID_CONFIG = Symbol('invalid-config'); -// Create API and attach methods -const api: LegacyTsjsApi = (w.tsjs ??= {} as LegacyTsjsApi); -api.version = VERSION; -api.addAdUnits = addAdUnits; -api.renderAdUnit = renderAdUnit; -api.renderAllAdUnits = () => renderAllAdUnits(); -api.log = log; -api.setConfig = setConfig; -api.getConfig = getConfig; -// Provide core requestAds API -api.requestAds = requestAds; -// Defensive defaults: the edge injects adSlots (head-open) and bids (before -// ) only when the server-side ad stack runs for the request. When it -// is gated off (kill switch, consent fail-closed, bots, prefetch), page code -// reading window.tsjs.bids / window.tsjs.adSlots must still see defined -// values instead of throwing. Injected scripts overwrite these wholesale. -api.adSlots ??= []; -api.bids ??= {}; -// Point global tsjs -w.tsjs = api; +type BootstrapTarget = object & { + boot?: unknown; + que?: unknown; + _integrationConfig?: unknown; +}; -// Single shared queue -installQueue(api, w); +export type BrowserRuntimeCompositionFactory = ( + runtimeOptions: RuntimeOptions, + compositionOptions: Readonly> +) => Readonly<{ runtime: Runtime }>; -// Flush prior queued callbacks -for (const fn of pending) { +function bootstrapTarget(): BootstrapTarget | undefined { try { - if (typeof fn === 'function') { - fn.call(api); - log.debug('queue: flushed callback'); + const current = (window as unknown as { tsjs?: unknown }).tsjs; + if ( + (typeof current === 'object' || typeof current === 'function') && + current !== null + ) { + return current as BootstrapTarget; } + const target: BootstrapTarget = {}; + (window as unknown as { tsjs?: unknown }).tsjs = target; + return target; } catch { - /* ignore queued callback error */ + return undefined; } } -log.info('tsjs initialized', { - methods: [ - 'setConfig', - 'getConfig', - 'requestAds', - 'addAdUnits', - 'renderAdUnit', - 'renderAllAdUnits', - ], -}); +function snapshotConfigValue( + candidate: unknown, + seen: Set, + state: { nodes: number }, + depth = 0 +): unknown | typeof INVALID_CONFIG { + if ( + candidate === null || + typeof candidate === 'string' || + typeof candidate === 'boolean' + ) { + return candidate; + } + if (typeof candidate === 'number') { + return Number.isFinite(candidate) ? candidate : INVALID_CONFIG; + } + if (typeof candidate !== 'object' || depth > MAX_CONFIG_DEPTH || seen.has(candidate)) { + return INVALID_CONFIG; + } + if (state.nodes >= MAX_CONFIG_NODES) return INVALID_CONFIG; + seen.add(candidate); + state.nodes += 1; + try { + const isArray = Array.isArray(candidate); + const prototype = Object.getPrototypeOf(candidate) as unknown; + if ( + (isArray && prototype !== Array.prototype) || + (!isArray && prototype !== Object.prototype && prototype !== null) || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return INVALID_CONFIG; + } + const names = Object.getOwnPropertyNames(candidate); + if (names.length > MAX_CONFIG_MEMBERS + (isArray ? 1 : 0)) return INVALID_CONFIG; + if (isArray) { + const length = Object.getOwnPropertyDescriptor(candidate, 'length'); + if (!length || !('value' in length) || names.length !== length.value + 1) { + return INVALID_CONFIG; + } + const values: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + return INVALID_CONFIG; + } + const value = snapshotConfigValue(descriptor.value, seen, state, depth + 1); + if (value === INVALID_CONFIG) return INVALID_CONFIG; + values.push(value); + } + return Object.freeze(values); + } + const copy: Record = {}; + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + return INVALID_CONFIG; + } + const value = snapshotConfigValue(descriptor.value, seen, state, depth + 1); + if (value === INVALID_CONFIG) return INVALID_CONFIG; + copy[name] = value; + } + return Object.freeze(copy); + } catch { + return INVALID_CONFIG; + } +} + +function consumeIntegrationConfig( + target: BootstrapTarget +): Readonly> | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(target, '_integrationConfig'); + if (!descriptor) return Object.freeze({}); + if (!('value' in descriptor) || !descriptor.configurable) return undefined; + const candidate = descriptor.value; + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + (Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null) || + Object.getOwnPropertySymbols(candidate).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(candidate); + if (names.length > EMBEDDED_INTEGRATION_IDS.length) return undefined; + const configs: Record = {}; + const seen = new Set(); + const state = { nodes: 0 }; + for (const name of names) { + if (!KNOWN_INTEGRATIONS.has(name)) return undefined; + const configDescriptor = Object.getOwnPropertyDescriptor(candidate, name); + if (!configDescriptor || !configDescriptor.enumerable || !('value' in configDescriptor)) { + return undefined; + } + const value = snapshotConfigValue(configDescriptor.value, seen, state); + if (value === INVALID_CONFIG) return undefined; + configs[name] = value; + } + if (!Reflect.deleteProperty(target, '_integrationConfig')) return undefined; + return Object.freeze(configs); + } catch { + return undefined; + } +} + +function bootManifest(target: BootstrapTarget): unknown { + try { + const boot = Object.getOwnPropertyDescriptor(target, 'boot'); + if (!boot || !('value' in boot) || typeof boot.value !== 'object' || boot.value === null) { + return undefined; + } + const manifest = Object.getOwnPropertyDescriptor(boot.value, 'manifest'); + return manifest && 'value' in manifest ? manifest.value : undefined; + } catch { + return undefined; + } +} + +/** Claim the browser namespace and start the injected sole composition root. */ +export function startProductionRuntime( + createComposition: BrowserRuntimeCompositionFactory +): void { + const target = bootstrapTarget(); + if (!target) return; + const configs = consumeIntegrationConfig(target); + const composition = createComposition( + { + target, + releaseId: EMBEDDED_RELEASE_ID, + manifest: configs ? bootManifest(target) : undefined, + knownIntegrationIds: EMBEDDED_INTEGRATION_IDS, + getBindings: (id) => + Object.freeze({ + config: configs?.[id], + interfaces: Object.freeze({}), + }), + kernel: { + addAdUnits: () => Object.freeze({ registered: Object.freeze([]) }), + diagnostics: Object.freeze({}), + requestAds: async () => Object.freeze({ slots: Object.freeze([]) }), + }, + }, + {} + ); + if (!composition.runtime.start()) return; + + let requested = false; + const install = (): void => { + if (requested) return; + requested = true; + void composition.runtime.install(); + }; + queueMicrotask(install); +} diff --git a/crates/trusted-server-js/lib/src/core/release.ts b/crates/trusted-server-js/lib/src/core/release.ts index 125b2ccde..7256c269d 100644 --- a/crates/trusted-server-js/lib/src/core/release.ts +++ b/crates/trusted-server-js/lib/src/core/release.ts @@ -1,4 +1,10 @@ declare const __TSJS_EMBEDDED_RELEASE_ID_V1__: string; +declare const __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: readonly string[]; /** Build-stamped identity of the exact canonical production bundle set. */ export const EMBEDDED_RELEASE_ID = __TSJS_EMBEDDED_RELEASE_ID_V1__; + +/** Build-generated inventory of every integration bundle admitted by this release. */ +export const EMBEDDED_INTEGRATION_IDS = Object.freeze([ + ...__TSJS_EMBEDDED_INTEGRATION_IDS_V1__, +]); diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 7f8d5c798..1a347ef04 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -125,9 +125,19 @@ export interface BrowserAuctionBidV1 { renderSource: BidRenderSourceV1; } +/** Exact GAM placement metadata required to publish one server-projected slot. */ +export interface BrowserAuctionSlotV1 { + slot: string; + gamUnitPath: string; + divId: string; + formats: ReadonlyArray; + targeting: Record; +} + export interface BrowserAuctionProjectionV1 { version: 1; auction: AuctionDecisionSetV1; + slots: BrowserAuctionSlotV1[]; bids: BrowserAuctionBidV1[]; } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/index.ts b/crates/trusted-server-js/lib/src/integrations/creative/index.ts index e3589e496..fda336bbf 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/index.ts @@ -1,12 +1,14 @@ -// Entry point for the creative runtime: wires up click + image + iframe guards globally. -import { log } from '../../core/log'; -import type { TsCreativeConfig, CreativeWindow, TsCreativeApi } from '../../shared/globals'; -import { creativeGlobal, resolveWindow } from '../../shared/globals'; +// Legacy callable helpers remain exported until Task 22; production performs +// only the release-bound integration registration below. +import { EMBEDDED_RELEASE_ID } from '../../core/release'; +import type { TsCreativeConfig, TsCreativeApi } from '../../shared/globals'; +import { creativeGlobal } from '../../shared/globals'; import { installClickGuard } from './click'; import { installDynamicImageProxy } from './image'; import { installDynamicIframeProxy } from './iframe'; import type { CreativeGuardHandle } from './startup'; +import { createCreativeIntegrationRegistration } from './module'; export { installDynamicImageProxy } from './image'; export { installDynamicIframeProxy } from './iframe'; @@ -88,32 +90,14 @@ export const tsCreative: TsCreativeApi = { getConfig: getCreativeConfig, }; -try { - creativeGlobal.tscreative = tsCreative; -} catch (err) { - log.debug('tsjs-creative: failed to expose global tscreative', err); -} - export default tsCreative; -(function auto() { - // Auto-install on load so publishers just reference the bundle. - const maybeWindow = resolveWindow(); - if (!maybeWindow || typeof document === 'undefined') return; - - const win = maybeWindow as CreativeWindow; - const initialConfig = creativeGlobal.tsCreativeConfig ?? win.tsCreativeConfig; - if (initialConfig) { - mergeConfig(initialConfig); - } else { - creativeGlobal.tsCreativeConfig = { ...currentConfig }; +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createCreativeIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } - if (win.__ts_creative_installed) return; - win.__ts_creative_installed = true; - - installGuards(); - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => installGuards()); - } -})(); +} diff --git a/crates/trusted-server-js/lib/src/integrations/datadome/index.ts b/crates/trusted-server-js/lib/src/integrations/datadome/index.ts index b7dacdebc..5a24093bd 100644 --- a/crates/trusted-server-js/lib/src/integrations/datadome/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/datadome/index.ts @@ -1,23 +1,13 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installDataDomeGuard } from './script_guard'; - -/** - * DataDome integration for tsjs - * - * Installs a script guard to intercept dynamically inserted DataDome SDK - * scripts and rewrites them to use the first-party proxy endpoint. - * - * The guard intercepts: - * - Script elements with src containing js.datadome.co - * - Link preload elements for DataDome scripts - * - * URLs are rewritten to preserve the original path: - * - https://js.datadome.co/tags.js -> /integrations/datadome/tags.js - * - https://js.datadome.co/js/check -> /integrations/datadome/js/check - */ +import { createDataDomeIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installDataDomeGuard(); - log.info('DataDome integration initialized'); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createDataDomeIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } diff --git a/crates/trusted-server-js/lib/src/integrations/didomi/index.ts b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts index 3595b2f9a..f073f757a 100644 --- a/crates/trusted-server-js/lib/src/integrations/didomi/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts @@ -1,4 +1,7 @@ import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; + +import { createDidomiIntegrationRegistration } from './module'; const DEFAULT_CONSENT_PROXY_PATH = '/integrations/didomi/consent/'; @@ -47,7 +50,11 @@ export function installDidomiSdkProxy(): boolean { } if (typeof window !== 'undefined') { - installDidomiSdkProxy(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createDidomiIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + } } export default installDidomiSdkProxy; diff --git a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts index ca73f2482..5da50a318 100644 --- a/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts @@ -1,31 +1,13 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installGtmBeaconGuard } from './script_guard'; -import { installGtmGuard } from './script_guard'; - -/** - * Google Tag Manager integration for tsjs - * - * Installs guards to intercept GTM and Google Analytics traffic: - * - * 1. **Script guard** — intercepts dynamically inserted ` -// The HTML pipeline currently injects that inline script before the unified -// bundle, so the explicit call is best-effort only. To make activation robust -// regardless of script order, the module also checks for a pre-set enable flag -// immediately after registering the function. if (typeof window !== 'undefined') { - const win = window as unknown as Record; - - win.__tsjs_installGptShim = installGptShim; - - if (win.__tsjs_gpt_enabled === true) { - installGptShim(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createGptIntegrationRegistration(EMBEDDED_RELEASE_ID)]); } - - installTsAdInit(); - installSpaAuctionHook(); - installSlimPrebidLoader(); - installTsRenderBridge(); } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts index 52d0f62c5..ca1dff27f 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -8,7 +8,11 @@ import { isAuctionCandidateIdV1, isRendererReservationIdV1, } from '../../core/contracts/auction_projection'; -import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import type { + BrowserAuctionBidV1, + BrowserAuctionProjectionV1, + BrowserAuctionSlotV1, +} from '../../core/types'; import type { NavigationSession } from '../../kernel/sessions'; import { createSlotOperation, @@ -80,6 +84,7 @@ export interface GptWinnerPublicationInput extends Omit< readonly bid: BrowserAuctionBidV1; readonly googletag: GoogletagAdapter; readonly navigation: NavigationSession; + readonly placement: BrowserAuctionSlotV1; readonly pucBridge: Pick; readonly reservations: Pick; readonly slot: object; @@ -92,15 +97,20 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { const projection = input.navigation.currentAuctionProjection as BrowserAuctionProjectionV1 | undefined; const bid = input.bid; + const placement = input.placement; if ( !projection || !objectIsFrozenIntrinsic(projection) || !objectIsFrozenIntrinsic(bid) || !objectIsFrozenIntrinsic(bid.renderSource) || !objectIsFrozenIntrinsic(bid.targeting) || + !objectIsFrozenIntrinsic(placement) || + !objectIsFrozenIntrinsic(placement.formats) || + !objectIsFrozenIntrinsic(placement.targeting) || !isAuctionCandidateIdV1(bid.candidateId) || !isRendererReservationIdV1(bid.rendererReservationId) || bid.slot !== input.attempt.slot || + placement.slot !== bid.slot || input.attempt.navigationGeneration !== input.navigation.generation || input.owner.id !== input.attempt.id || input.owner.slot !== input.attempt.slot || @@ -126,6 +136,14 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { } } if (!exactBid) return false; + let exactPlacement = false; + for (let index = 0; index < projection.slots.length; index += 1) { + if (projection.slots[index] === placement) { + if (exactPlacement) return false; + exactPlacement = true; + } + } + if (!exactPlacement) return false; let exactWinner = false; for (let index = 0; index < projection.auction.results.length; index += 1) { const result = projection.auction.results[index]; @@ -145,31 +163,45 @@ function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { } function targetingEntries( - bid: BrowserAuctionBidV1 + bid: BrowserAuctionBidV1, + placement: BrowserAuctionSlotV1 ): readonly (readonly [string, string])[] | undefined { try { - const unsortedNames = objectGetOwnPropertyNamesIntrinsic(bid.targeting); - const names: string[] = []; - for (let index = 0; index < unsortedNames.length; index += 1) { - const name = unsortedNames[index]; - if (name === undefined) return undefined; - let insertion = names.length; - while (insertion > 0 && (names[insertion - 1] as string) > name) insertion -= 1; - for (let move = names.length; move > insertion; move -= 1) { - names[move] = names[move - 1] as string; - } - names[insertion] = name; - } - if (names.length > 32 || objectGetOwnPropertySymbolsIntrinsic(bid.targeting).length !== 0) { + const bidNames = objectGetOwnPropertyNamesIntrinsic(bid.targeting); + const placementNames = objectGetOwnPropertyNamesIntrinsic(placement.targeting); + if ( + bidNames.length > 32 || + placementNames.length > 32 || + objectGetOwnPropertySymbolsIntrinsic(bid.targeting).length !== 0 || + objectGetOwnPropertySymbolsIntrinsic(placement.targeting).length !== 0 + ) { return undefined; } + const names: string[] = []; + const insertNames = (source: readonly string[]): boolean => { + for (let index = 0; index < source.length; index += 1) { + const name = source[index]; + if (!name || name === 'hb_adid') return false; + let insertion = 0; + while (insertion < names.length && (names[insertion] as string) < name) insertion += 1; + if (names[insertion] === name) continue; + for (let move = names.length; move > insertion; move -= 1) { + names[move] = names[move - 1] as string; + } + names[insertion] = name; + } + return true; + }; + if (!insertNames(placementNames) || !insertNames(bidNames)) return undefined; const entries: Array = [ Object.freeze(['hb_adid', bid.rendererReservationId]), ]; for (let index = 0; index < names.length; index += 1) { const key = names[index]; - if (!key || key === 'hb_adid') return undefined; - const descriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); + if (!key) return undefined; + const bidDescriptor = objectGetOwnPropertyDescriptorIntrinsic(bid.targeting, key); + const placementDescriptor = objectGetOwnPropertyDescriptorIntrinsic(placement.targeting, key); + const descriptor = bidDescriptor ?? placementDescriptor; if ( !descriptor || !descriptor.enumerable || @@ -266,7 +298,7 @@ export async function publishGptWinner( disposeArtifact(); return failAttempt('slot_unresolved'); } - const entries = targetingEntries(input.bid); + const entries = targetingEntries(input.bid, input.placement); if (!entries) { disposeArtifact(); return failAttempt('descriptor_invalid'); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts index e552f9112..e7a78af16 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts @@ -1,4 +1,5 @@ import type { GptDiagnosticsApi } from '../../core/types'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; import { GptDiagnosticsApiController } from './api'; import { GptDiagnosticsBadgeManager } from './badges'; @@ -7,6 +8,7 @@ import type { GptDiagnosticsFactBuffer } from './facts'; import { GptDiagnosticsObserver } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; import { GptDiagnosticsStore } from './store'; +import { createGptDiagnosticsIntegrationRegistration } from './module'; type GptDiagnosticsWindow = Window & typeof globalThis; @@ -105,3 +107,13 @@ export function createGptDiagnosticsRuntime( currentApi: (): GptDiagnosticsApi | undefined => active?.api, }); } + +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createGptDiagnosticsIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/index.ts b/crates/trusted-server-js/lib/src/integrations/lockr/index.ts index e7b98e2cf..3d94101d7 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/index.ts @@ -1,107 +1,11 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installLockrGuard } from './script_guard'; - -// Type definition for Lockr global -declare const identityLockr: IdentityLockr | undefined; - -interface IdentityLockr { - host: string; - app_id: string; - expiryDateKeys: string[]; - firstPartyCookies: string[]; - canRefreshToken: boolean; - macroDetectionEnabled: boolean; - iluiMacroDetection: boolean; - gdprApplies: boolean; - consentString: string; - gppString: string; - ccpaString: string; - isUTMTagsLoaded: boolean; - isFirstPartyCookiesLoaded: boolean; - allowedUTMTags: string[]; - lockrTrackingID: string; - panoramaClientId: string; - writeToDeviceConsentEUID: boolean; - id5JSEnabled: boolean; - firstIDPassHEM: boolean; - panoramaPassHEM: boolean; - firstIDEnabled: boolean; - panoramaEnabled: boolean; - isAdelphicEnabled: boolean; - os: string; - browser: string; - country: string; - city: string; - latitude: string; - longitude: string; - ip: string; - hashedUserAgent: string; - tokenMappings: Record; - tokenSourceMappings: Record; - identitProvidersType: Record; - identityIdEncryptionSalt: string; -} - -/** - * Install the Lockr shim to rewrite API endpoints to first-party domain. - * This function is called after the Lockr SDK has loaded and initialized. - */ -function installLockrShim() { - log.info('Installing Lockr shim - rewriting API host to first-party domain'); - - if (typeof identityLockr === 'undefined' || !identityLockr) { - log.warn('Lockr shim: identityLockr global not found'); - return; - } - - const host = window.location.host; - const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; - - // Store original host for debugging - const originalHost = identityLockr.host; - - // Rewrite to first-party domain - // The Lockr SDK will now make all API calls through our proxy - identityLockr.host = `${protocol}://${host}/integrations/lockr/api`; - - log.info('Lockr shim installed', { - originalHost, - newHost: identityLockr.host, - appId: identityLockr.app_id, - }); -} - -/** - * Wait for Lockr SDK to be available before installing shim. - * Polls for SDK availability with a maximum number of attempts. - * - * @param callback - Function to call when SDK is available - * @param maxAttempts - Maximum number of polling attempts (default: 50) - */ -function waitForLockrSDK(callback: () => void, maxAttempts = 50) { - let attempts = 0; - - const check = () => { - attempts++; - - // Check if identityLockr global exists and is initialized with host - if (typeof identityLockr !== 'undefined' && identityLockr && identityLockr.host) { - log.info('Lockr SDK detected, installing shim'); - callback(); - } else if (attempts < maxAttempts) { - // Check again in 50ms - setTimeout(check, 50); - } else { - log.warn('Lockr SDK not detected after', maxAttempts * 50, 'ms'); - } - }; - - check(); -} +import { createLockrIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installLockrGuard(); - - waitForLockrSDK(() => installLockrShim()); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createLockrIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + } } diff --git a/crates/trusted-server-js/lib/src/integrations/osano/index.ts b/crates/trusted-server-js/lib/src/integrations/osano/index.ts index 135b44591..afd29ff9a 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts @@ -4,7 +4,14 @@ export { mirrorOsanoConsent, } from './consent_mirror'; -import { initializeOsanoConsentMirror } from './consent_mirror'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -// Legacy entry point retained until the coordinated Task 19 wiring cutover. -initializeOsanoConsentMirror(); +import { createOsanoIntegrationRegistration } from './module'; + +if (typeof window !== 'undefined') { + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [createOsanoIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/index.ts b/crates/trusted-server-js/lib/src/integrations/permutive/index.ts index 60eb5134a..6385eac8a 100644 --- a/crates/trusted-server-js/lib/src/integrations/permutive/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/permutive/index.ts @@ -1,114 +1,13 @@ -import { log } from '../../core/log'; -import { registerContextProvider } from '../../core/context'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { installPermutiveGuard } from './script_guard'; -import { getPermutiveSegments } from './segments'; - -declare const permutive: { - config: { - advertiserApiVersion: string; - apiHost: string; - apiKey: string; - apiProtocol: string; - apiVersion: string; - cdnBaseUrl: string; - cdnProtocol: string; - classificationModelsApiVersion: string; - consentRequired: boolean; - cookieDomain: string; - cookieExpiry: string; - cookieName: string; - environment: string; - eventsCacheLimitBytes: number; - eventsTTLInDays: number | null; - localStorageDebouncedKeys: string[]; - localStorageWriteDelay: number; - localStorageWriteMaxDelay: number; - loggingEnabled: boolean; - metricsSamplingPercentage: number; - permutiveDataMiscKey: string; - permutiveDataQueriesKey: string; - prebidAuctionsRandomDownsamplingThreshold: number; - pxidHost: string; - requestTimeout: number; - sdkErrorsApiVersion: string; - sdkType: string; - secureSignalsApiHost: string; - segmentSyncApiHost: string; - sendClientErrors: boolean; - stateNamespace: string; - tracingEnabled: boolean; - viewId: string; - watson: { - enabled: boolean; - }; - windowKey: string; - workspaceId: string; - }; -}; - -function installPermutiveShim() { - log.info('Installing Permutive shim - rewriting API hosts to first-party domain'); - - const host = window.location.host; - const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; - - permutive.config.apiHost = host + '/integrations/permutive/api'; - permutive.config.apiProtocol = protocol; - - permutive.config.secureSignalsApiHost = host + '/integrations/permutive/secure-signal'; - - permutive.config.segmentSyncApiHost = host + '/integrations/permutive/sync'; - - permutive.config.cdnBaseUrl = host + '/integrations/permutive/cdn'; - permutive.config.cdnProtocol = protocol; - - log.info('Permutive shim installed', { - apiHost: permutive.config.apiHost, - secureSignalsApiHost: permutive.config.secureSignalsApiHost, - segmentSyncApiHost: permutive.config.segmentSyncApiHost, - cdnBaseUrl: permutive.config.cdnBaseUrl, - }); -} - -/** - * Wait for Permutive SDK to be available before installing shim. - * Polls for SDK availability with a maximum number of attempts. - * - * @param callback - Function to call when SDK is available - * @param maxAttempts - Maximum number of polling attempts (default: 50) - */ -function waitForPermutiveSDK(callback: () => void, maxAttempts = 50) { - let attempts = 0; - - const check = () => { - attempts++; - - // Check if permutive global exists and is initialized with config - if (typeof permutive !== 'undefined' && permutive?.config) { - log.info('Permutive SDK detected, installing shim'); - callback(); - } else if (attempts < maxAttempts) { - // Check again in 50ms - setTimeout(check, 50); - } else { - log.warn('Permutive SDK not detected after', maxAttempts * 50, 'ms'); - } - }; - - check(); -} +import { createPermutiveIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installPermutiveGuard(); - - // Register a context provider so Permutive segments are included in auction - // requests. Core calls collectContext() before every /auction POST — this - // keeps all Permutive localStorage knowledge inside this integration. - registerContextProvider('permutive', () => { - const segments = getPermutiveSegments(); - return segments.length > 0 ? { permutive_segments: segments } : undefined; - }); - - waitForPermutiveSDK(() => installPermutiveShim()); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createPermutiveIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 4a25670ff..3e4606207 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -14,6 +14,7 @@ import type _pbjsDefault from 'prebid.js'; import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from '../../core/trace'; import { buildAdRequest, @@ -25,6 +26,7 @@ import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot, BrowserAuctionBidV1, RenderRecord } from '../../core/types'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +import { createPrebidIntegrationRegistration } from './module'; /** * Prebid.js public API surface (type-only; erased at build time). @@ -1695,30 +1697,13 @@ export function installPrebidRenderTrace(): void { listen('adRenderFailed', 'failed'); } -// Self-initialize when loaded in a browser (same pattern as other integrations). if (typeof window !== 'undefined') { - installPrebidNpm(); - // When the external bundle failed to load, installPrebidNpm bailed out and - // pbjs.requestBids is undefined. Installing the refresh handler anyway - // would clear TS-applied GPT targeting on every publisher refresh and then - // fail to run the replacement auction — leave GPT untouched instead. - if (hasPrebidJsApi()) { - installRefreshHandler(); - installPrebidRenderTrace(); - // The slim-Prebid lazy loader appends this bundle from a window.load - // handler, so `load` may already have fired by the time this code runs — - // waiting for it again would skip user ID setup entirely on that path. - if (document.readyState === 'complete') { - installUserIdModules(); - } else { - window.addEventListener( - 'load', - () => { - installUserIdModules(); - }, - { once: true } - ); - } + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createPrebidIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } } 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 442d14760..298ce1ed2 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -1,7 +1,6 @@ -import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -import { initializeSourcepointConsentMirror } from './consent_mirror'; -import { installSourcepointGuard } from './script_guard'; +import { createSourcepointIntegrationRegistration } from './module'; export { disposeSourcepointConsentMirror, @@ -9,15 +8,12 @@ export { mirrorSourcepointConsent, } from './consent_mirror'; -type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { rewriteSdk?: boolean }; -}; - -// Legacy entry point retained until the coordinated Task 19 wiring cutover. if (typeof window !== 'undefined') { - if ((window as SourcepointWindow).__tsjs_sourcepoint?.rewriteSdk !== false) { - installSourcepointGuard(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createSourcepointIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } - initializeSourcepointConsentMirror(); - log.info('Sourcepoint integration initialized'); } diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts index 8424d20af..450d54663 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts @@ -1,82 +1,13 @@ -import type { LegacyTsjsApi } from '../../core/types'; -import { installQueue } from '../../core/queue'; -import { log } from '../../core/log'; -import { resolvePrebidWindow } from '../../shared/globals'; -import type { PrebidWindow } from '../../shared/globals'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; -type TestlightCallback = () => void; - -type TestlightGlobal = { - que?: TestlightCallback[]; -}; - -type TestlightWindow = PrebidWindow & { - testlight?: TestlightGlobal; -}; - -function ensureTsjsApi(win: TestlightWindow): LegacyTsjsApi { - if (win.tsjs) return win.tsjs; - const stub: LegacyTsjsApi = { - version: '0.0.0', - que: [], - addAdUnits: () => undefined, - renderAdUnit: () => undefined, - renderAllAdUnits: () => undefined, - }; - win.tsjs = stub; - return stub; -} - -function installTestlightQueue(api: LegacyTsjsApi, win: TestlightWindow): void { - if (!Array.isArray(api.que)) { - installQueue(api, win); - } -} - -function flushCallbacks(queue: TestlightCallback[], api: LegacyTsjsApi): void { - while (queue.length > 0) { - const fn = queue.shift(); - if (typeof fn !== 'function') { - continue; - } - try { - if (Array.isArray(api.que)) { - api.que.push(fn); - } else { - fn.call(api); - } - log.debug('testlight shim: flushed callback'); - } catch (err) { - log.debug('testlight shim: queued callback threw', err); - } - } -} - -export function installTestlightShim(): boolean { - const win = resolvePrebidWindow() as TestlightWindow; - const api = ensureTsjsApi(win); - installTestlightQueue(api, win); - - const testlight = (win.testlight = win.testlight ?? {}); - const pending: TestlightCallback[] = Array.isArray(testlight.que) ? [...testlight.que] : []; - const queue: TestlightCallback[] = []; - testlight.que = queue; - - const originalPush = queue.push.bind(queue); - queue.push = function (...callbacks: TestlightCallback[]): number { - const len = originalPush(...callbacks); - flushCallbacks(queue, api); - return len; - }; - - if (pending.length > 0) { - queue.push(...pending); - } - - log.info('testlight shim installed', { queuedCallbacks: queue.length }); - return true; -} +import { createTestlightIntegrationRegistration } from './module'; if (typeof window !== 'undefined') { - installTestlightShim(); + const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) + ?._registerIntegration; + if (typeof register === 'function') { + Reflect.apply(register, window.tsjs, [ + createTestlightIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); + } } diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index 84b777d5e..b6c76951e 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -13,6 +13,7 @@ import type { BootFailureReason } from './integration_registry'; const SAFE_PROJECTION = { version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], } as const; diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 191aafe5e..815aadb19 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -154,8 +154,10 @@ class RuntimeOwner implements Runtime { const bootCandidate = this.bootCandidate(); this.fallbackBoot = buildFallbackBoot(EMBEDDED_RELEASE_ID, bootCandidate); this.registry = createIntegrationRegistry({ - manifest: - this.options.releaseId === EMBEDDED_RELEASE_ID ? this.options.manifest : undefined, + // The manifest validator binds releaseId directly to the embedded build + // stamp, so a separate comparison would duplicate the stamp in minified + // core output without strengthening the ABI check. + manifest: this.options.manifest, releaseId: EMBEDDED_RELEASE_ID, knownIntegrationIds: this.options.knownIntegrationIds, startedAtMs, diff --git a/crates/trusted-server-js/lib/src/services/projections.ts b/crates/trusted-server-js/lib/src/services/projections.ts index 926ef2259..2199b211f 100644 --- a/crates/trusted-server-js/lib/src/services/projections.ts +++ b/crates/trusted-server-js/lib/src/services/projections.ts @@ -13,11 +13,17 @@ export interface PreparedProjectionSlots { readonly rollback: () => void; } +/** Exact slot identity and DOM aliases reserved with one admitted projection. */ +export interface ProjectionSlotRegistration { + readonly registeredSlotId: string; + readonly domAliases: readonly string[]; +} + /** Slot-registry transaction boundary consumed by the page-bids controller. */ export interface ProjectionSlotRegistry { readonly prepareProjectionSlots: ( ownerGeneration: object, - slots: readonly string[], + slots: readonly ProjectionSlotRegistration[], maximumActiveSlots: number ) => PreparedProjectionSlots | undefined; } @@ -72,31 +78,36 @@ function recursivelyFreeze(value: unknown, visited = new Set()): boolean } } -function projectedSlots(projection: object): readonly string[] | undefined { +function projectedSlots(projection: object): readonly ProjectionSlotRegistration[] | undefined { try { - const auctionDescriptor = Object.getOwnPropertyDescriptor(projection, 'auction'); - if (!auctionDescriptor || !('value' in auctionDescriptor)) return undefined; - const auction = auctionDescriptor.value; - if (typeof auction !== 'object' || auction === null) return undefined; - const resultsDescriptor = Object.getOwnPropertyDescriptor(auction, 'results'); - if (!resultsDescriptor || !('value' in resultsDescriptor)) return undefined; - const results = resultsDescriptor.value; - if (!Array.isArray(results) || results.length > MAX_ACTIVE_SLOT_RECORDS) return undefined; - const slots: string[] = []; + const slotsDescriptor = Object.getOwnPropertyDescriptor(projection, 'slots'); + if (!slotsDescriptor || !('value' in slotsDescriptor)) return undefined; + const projected = slotsDescriptor.value; + if (!Array.isArray(projected) || projected.length > MAX_ACTIVE_SLOT_RECORDS) return undefined; + const slots: ProjectionSlotRegistration[] = []; const seen = new Set(); - for (const result of results) { - if (typeof result !== 'object' || result === null) return undefined; - const slotDescriptor = Object.getOwnPropertyDescriptor(result, 'slot'); + for (const placement of projected) { + if (typeof placement !== 'object' || placement === null) return undefined; + const slotDescriptor = Object.getOwnPropertyDescriptor(placement, 'slot'); + const divDescriptor = Object.getOwnPropertyDescriptor(placement, 'divId'); if ( !slotDescriptor || !('value' in slotDescriptor) || - typeof slotDescriptor.value !== 'string' + typeof slotDescriptor.value !== 'string' || + !divDescriptor || + !('value' in divDescriptor) || + typeof divDescriptor.value !== 'string' ) { return undefined; } if (seen.has(slotDescriptor.value)) return undefined; seen.add(slotDescriptor.value); - slots.push(slotDescriptor.value); + slots.push( + Object.freeze({ + registeredSlotId: slotDescriptor.value, + domAliases: Object.freeze([divDescriptor.value]), + }) + ); } return Object.freeze(slots); } catch { diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index fcbfe584b..b7a43fb2e 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -17,7 +17,11 @@ import { } from '../adapters/googletag'; import type { NavigationSession } from '../kernel/sessions'; -import type { PreparedProjectionSlots, ProjectionSlotRegistry } from './projections'; +import type { + PreparedProjectionSlots, + ProjectionSlotRegistration, + ProjectionSlotRegistry, +} from './projections'; /** Shared maximum across server-projected and programmatically admitted slots. */ export const MAX_ACTIVE_SLOT_RECORDS = 256; @@ -149,7 +153,7 @@ export interface SlotService { ) => boolean; readonly prepareProjectionSlots: ( owner: NavigationSession, - slots: readonly string[] + slots: readonly ProjectionSlotRegistration[] ) => PreparedProjectionSlots | undefined; readonly claimPublisherGptSlot: ( call: GoogletagPublisherDefineSlotCall @@ -3133,19 +3137,27 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }, prepareProjectionSlots: ( owner: NavigationSession, - slots: readonly string[] + slots: readonly ProjectionSlotRegistration[] ): PreparedProjectionSlots | undefined => { if (!owner.isCurrent() || !Array.isArray(slots)) return undefined; - const copied = Object.freeze([...slots]); + let copied: readonly SlotRegistration[]; + try { + copied = Object.freeze( + slots.map((slot) => ({ + registeredSlotId: slot.registeredSlotId, + domAliases: Object.freeze([...slot.domAliases]), + source: 'server' as const, + })) + ); + } catch { + return undefined; + } let committedRecords: readonly SlotRecord[] | undefined; return Object.freeze({ ownerGeneration: owner.generation, commit: (): boolean => { if (committedRecords) return false; - const result = register( - owner, - copied.map((registeredSlotId) => ({ registeredSlotId, source: 'server' as const })) - ); + const result = register(owner, copied); if (!result.ok) return false; committedRecords = result.records; return true; @@ -3171,7 +3183,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { Object.freeze({ prepareProjectionSlots: ( ownerGeneration: object, - slots: readonly string[], + slots: readonly ProjectionSlotRegistration[], maximumActiveSlots: number ) => { if ( diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 4b57e7c9d..6e073ee16 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -76,6 +76,72 @@ describe('browser googletag adapter readiness', () => { expect(ready.display).toHaveBeenCalledWith('slot-a'); }); + it('defines and adopts one GPT slot as a synchronous rollback-capable transaction', async () => { + const ready = createReadyGoogletag(); + const slot = { addService: vi.fn() }; + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + const commit = vi.fn(() => true); + const rollback = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + const operation = adapter.run((gpt) => + gpt.transactionalDefine( + { + adUnitPath: '/123/slot-a', + elementId: 'slot-a', + sizes: [[300, 250]], + }, + () => true, + (candidate) => { + expect(candidate).toBe(slot); + return Object.freeze({ commit, rollback }); + } + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'defined', slot }); + expect(ready.googletag.defineSlot).toHaveBeenCalledExactlyOnceWith( + '/123/slot-a', + [[300, 250]], + 'slot-a' + ); + expect(slot.addService).toHaveBeenCalledExactlyOnceWith(ready.pubads); + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect(ready.googletag.destroySlots).not.toHaveBeenCalled(); + expect(slot.addService.mock.invocationCallOrder[0]).toBeLessThan( + commit.mock.invocationCallOrder[0]! + ); + }); + + it('destroys a newly defined GPT slot when its navigation becomes stale before adoption', async () => { + const ready = createReadyGoogletag(); + const slot = { addService: vi.fn() }; + ready.googletag.defineSlot.mockReturnValue(slot); + ready.googletag.destroySlots.mockReturnValue(true); + const isGenerationCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValue(false); + const prepareCommit = vi.fn(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + + const operation = adapter.run((gpt) => + gpt.transactionalDefine( + { + adUnitPath: '/123/slot-a', + elementId: 'slot-a', + sizes: [[300, 250]], + }, + isGenerationCurrent, + prepareCommit + ) + ); + + await expect(operation.result).resolves.toEqual({ status: 'discarded' }); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(slot.addService).not.toHaveBeenCalled(); + expect(ready.googletag.destroySlots).toHaveBeenCalledExactlyOnceWith([slot]); + }); + it('marks and measures only the first TS-authoritative display', async () => { const ready = createReadyGoogletag(); const performance = { diff --git a/crates/trusted-server-js/lib/test/composition/browser.test.ts b/crates/trusted-server-js/lib/test/composition/browser.test.ts index f6297b68e..747621780 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -35,7 +35,7 @@ import { } from '../../src/composition/browser'; import { log as localLog } from '../../src/core/log'; import { TRACE_PANEL_ID } from '../../src/core/trace'; -import type { BrowserAuctionBidV1 } from '../../src/core/types'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../src/core/types'; import { createCreativeIntegrationRegistration } from '../../src/integrations/creative/module'; import { createDataDomeIntegrationRegistration } from '../../src/integrations/datadome/module'; import { createDidomiIntegrationRegistration } from '../../src/integrations/didomi/module'; @@ -69,20 +69,51 @@ function createTarget() { }; } +function browserSlotPlacement(slot: string, divId = slot) { + return Object.freeze({ + slot, + gamUnitPath: `/123/${slot}`, + divId, + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }); +} + function fakeGoogletagAdapter( bindingStatus: () => GoogletagBindingStatus = () => 'pending' ): GoogletagAdapter { return Object.freeze({ ...createNoopGoogletagAdapter(), bindingStatus }); } -function synchronousGptAdapter() { +function synchronousGptAdapter(initialSlots: readonly object[] = []) { const listeners = new Map void>>(); + const physicalSlots: object[] = [...initialSlots]; const targeting = new WeakMap>(); const bindingToken = Object.freeze({}); + const display = vi.fn(); const refresh = vi.fn(); const diagnosticsSlots = new WeakMap(); let diagnosticsObserver: GoogletagDiagnosticsObserver | undefined; let publisherObserver: GoogletagPublisherCallObserver | undefined; + const transactionalDefine: GoogletagFacade['transactionalDefine'] = ( + definition, + isGenerationCurrent, + prepareCommit + ) => { + if (!isGenerationCurrent()) return Object.freeze({ status: 'discarded' as const }); + const slot = { + addService: vi.fn(), + getAdUnitPath: () => definition.adUnitPath, + getSlotElementId: () => definition.elementId, + }; + const admission = prepareCommit(slot); + if (!admission.commit() || !isGenerationCurrent()) { + admission.rollback(); + return Object.freeze({ status: 'discarded' as const }); + } + physicalSlots.push(slot); + return Object.freeze({ status: 'defined' as const, slot }); + }; const facade: GoogletagFacade = Object.freeze({ adUnitPath: (slot: object) => 'getAdUnitPath' in slot && typeof slot.getAdUnitPath === 'function' @@ -94,7 +125,8 @@ function synchronousGptAdapter() { if (key === undefined) values?.clear(); else values?.delete(key); }), - display: vi.fn(), + transactionalDefine, + display, getTargeting: vi.fn((slot: object, key: string) => Object.freeze([...(targeting.get(slot)?.get(key) ?? [])]) ), @@ -107,7 +139,11 @@ function synchronousGptAdapter() { targeting.set(slot, values); values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); }), - slots: () => Object.freeze([]), + slotElementId: (slot: object) => + 'getSlotElementId' in slot && typeof slot.getSlotElementId === 'function' + ? slot.getSlotElementId() + : undefined, + slots: () => Object.freeze([...physicalSlots]), subscribe: (eventType: string, listener: (event: unknown) => void) => { const registered = listeners.get(eventType) ?? new Set(); registered.add(listener); @@ -180,6 +216,7 @@ function synchronousGptAdapter() { } }, diagnosticsObserverActive: () => diagnosticsObserver !== undefined, + display, listenerInventory: () => Object.freeze( [...listeners.entries()] @@ -191,7 +228,9 @@ function synchronousGptAdapter() { if (!observer?.refresh) throw new Error('Publisher observer is unavailable'); return observer.refresh(call); }, + physicalSlots: () => Object.freeze([...physicalSlots]), refresh, + targetingFor: (slot: object) => new Map(targeting.get(slot) ?? []), }; } @@ -386,6 +425,7 @@ describe('browser composition', () => { }), ]), }), + slots: Object.freeze([browserSlotPlacement('slot-one')]), bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( @@ -484,6 +524,10 @@ describe('browser composition', () => { navigation.currentAuctionProjection as Readonly<{ bids: readonly BrowserAuctionBidV1[] }> ).bids[0]; if (!projectedBid) throw new Error('Expected the parsed projected winner'); + const projectedPlacement = ( + navigation.currentAuctionProjection as Readonly> + ).slots[0]; + if (!projectedPlacement) throw new Error('Expected the parsed projected placement'); let fallback: RenderAttempt | undefined; const operation = await composition.publishGptWinnerForTest({ artifact, @@ -495,6 +539,7 @@ describe('browser composition', () => { }, operation: 'refresh', owner: ownerResult.value, + placement: projectedPlacement, requestClass: 'primary', slot: physicalSlot, }); @@ -529,6 +574,203 @@ describe('browser composition', () => { slotElement.remove(); }); + it('publishes the accepted initial projection through the production GPT lifecycle', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const placement = browserSlotPlacement('initial-slot'); + const bid = Object.freeze({ + candidateId: 'AAAAAAAAAAAA', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'initial-upstream', + cpm: 1.5, + currency: 'USD' as const, + targeting: Object.freeze({ hb_bidder: 'trusted', pos: 'bid' }), + rendererReservationId: `r1_${'i'.repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
initial winner
', + width: 300, + height: 250, + }), + }); + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial-production', + results: [ + { slot: placement.slot, outcome: 'winner' as const, candidateId: bid.candidateId }, + ], + }, + slots: [{ ...placement, targeting: { pos: 'placement', section: 'news' } }], + bids: [bid], + }; + const element = document.createElement('div'); + element.id = placement.divId; + document.body.append(element); + let prefix = 0; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: projection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + await vi.waitFor(() => expect(gpt.physicalSlots()).toHaveLength(1)); + const physicalSlot = gpt.physicalSlots()[0]; + expect(physicalSlot).toBeDefined(); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(physicalSlot); + expect(gpt.refresh).not.toHaveBeenCalled(); + expect(gpt.targetingFor(physicalSlot!)).toEqual( + new Map([ + ['hb_adid', [bid.rendererReservationId]], + ['hb_bidder', ['trusted']], + ['pos', ['bid']], + ['section', ['news']], + ]) + ); + gpt.emit('slotRequested', { slot: physicalSlot }); + gpt.emit('slotRenderEnded', { + isEmpty: true, + responseIdentifier: 'initial-empty-response', + slot: physicalSlot, + }); + await vi.waitFor(() => expect(element.querySelector('iframe')).not.toBeNull()); + } finally { + composition.runtime.dispose(); + element.remove(); + } + }); + + it('reuses one publisher GPT slot resolved through a unique responsive DOM prefix', async () => { + const releaseId = 'a'.repeat(64); + const publisherSlot = { + getAdUnitPath: () => '/publisher/existing', + getSlotElementId: () => 'responsive-mobile', + }; + const gpt = synchronousGptAdapter([publisherSlot]); + const placement = { + slot: 'responsive-slot', + gamUnitPath: '/123/responsive-slot', + divId: 'responsive-', + formats: [[300, 250]], + targeting: {}, + }; + const bid = { + candidateId: 'CCCCCCCCCCCC', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'responsive-upstream', + cpm: 1, + currency: 'USD' as const, + targeting: {}, + rendererReservationId: `r1_${'r'.repeat(22)}`, + renderSource: { + type: 'adm' as const, + version: 1 as const, + adm: '
responsive winner
', + width: 300, + height: 250, + }, + }; + const element = document.createElement('div'); + element.id = 'responsive-mobile'; + document.body.append(element); + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'responsive-initial', + results: [ + { slot: placement.slot, outcome: 'winner' as const, candidateId: bid.candidateId }, + ], + }, + slots: [placement], + bids: [bid], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(7); + return target; + }, + }), + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + await vi.waitFor(() => expect(gpt.refresh).toHaveBeenCalledOnce()); + expect(gpt.physicalSlots()).toEqual([publisherSlot]); + expect(gpt.display).not.toHaveBeenCalled(); + expect(gpt.refresh).toHaveBeenCalledExactlyOnceWith( + [publisherSlot], + Object.freeze({ changeCorrelator: false }) + ); + } finally { + composition.runtime.dispose(); + element.remove(); + } + }); + it('derives exact APS validation coordinates only for the real browser target', () => { const renderer = { type: 'aps', @@ -629,6 +871,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -709,6 +952,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -852,6 +1096,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -905,6 +1150,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -992,6 +1238,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1080,6 +1327,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1210,6 +1458,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1328,6 +1577,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1411,6 +1661,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1542,6 +1793,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -1615,6 +1867,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative, @@ -1666,6 +1919,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: false, renderGuard: false }, @@ -1735,6 +1989,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative, @@ -1786,6 +2041,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, @@ -1864,6 +2120,7 @@ describe('browser composition', () => { }), ]), }), + slots: Object.freeze([browserSlotPlacement(bid.slot)]), bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( @@ -2015,6 +2272,7 @@ describe('browser composition', () => { }), ]), }), + slots: Object.freeze([browserSlotPlacement(bid.slot)]), bids: Object.freeze([bid]), }); const composition = createTestBrowserRuntimeComposition( @@ -2168,6 +2426,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('slot')], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2245,6 +2504,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'initial-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('initial-slot')], bids: [], }; let prefix = 0; @@ -2375,6 +2635,7 @@ describe('browser composition', () => { auctionId: 'spa', results: [{ slot: 'spa-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('spa-slot')], bids: [], }) ).toEqual({ status: 'committed' }); @@ -2400,6 +2661,225 @@ describe('browser composition', () => { expect(composition.rendererNonceRegistryForTest()).toBeUndefined(); }); + it('commits canonical page-bids into a replacement navigation without mutating boot', async () => { + const nativeReplaceState = history.replaceState.bind(history); + const releaseId = 'a'.repeat(64); + const initialProjection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'initial-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('initial-slot')], + bids: [], + }; + const spaProjection = { + version: 1, + auction: { + version: 1, + auctionId: 'spa-auction', + results: [{ slot: 'spa-slot', outcome: 'no_bid' }], + }, + slots: [browserSlotPlacement('spa-slot')], + bids: [], + }; + const fetchPageBids = vi.fn(async () => ({ + ok: true, + json: async () => spaProjection, + })); + const target: Record = {}; + const composition = createTestBrowserRuntimeComposition( + { + target, + releaseId, + manifest: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: initialProjection, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: fakeGoogletagAdapter(), + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + pageBidsFetcherForTest: fetchPageBids, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + const boot = (target as { boot: Readonly<{ auctionProjection: object }> }).boot; + const initialNavigation = composition.runtimeSessionForTest()?.currentNavigation; + + history.pushState({}, '', '/spa-route?section=one'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledOnce()); + expect(fetchPageBids).toHaveBeenCalledWith( + '/_ts/page-bids?path=%2Fspa-route%3Fsection%3Done', + expect.objectContaining({ + credentials: 'include', + headers: { 'X-TSJS-Page-Bids': '1' }, + signal: expect.any(AbortSignal), + }) + ); + await vi.waitFor(() => + expect( + composition.runtimeSessionForTest()?.currentNavigation?.currentAuctionProjection + ).toMatchObject({ auction: { auctionId: 'spa-auction' } }) + ); + + expect(composition.runtimeSessionForTest()?.currentNavigation).not.toBe(initialNavigation); + expect(initialNavigation?.disposed).toBe(true); + expect(composition.projectionSlotsForTest()).toEqual(['spa-slot']); + expect(boot.auctionProjection).toMatchObject({ auction: { auctionId: 'initial' } }); + expect(Object.isFrozen(boot.auctionProjection)).toBe(true); + + history.replaceState({}, '', '/spa-replaced'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(2)); + expect(fetchPageBids).toHaveBeenLastCalledWith( + '/_ts/page-bids?path=%2Fspa-replaced', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + + nativeReplaceState({}, '', '/spa-popped'); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(3)); + window.dispatchEvent(new PopStateEvent('popstate')); + await Promise.resolve(); + expect(fetchPageBids).toHaveBeenCalledTimes(3); + + fetchPageBids.mockResolvedValueOnce({ + ok: false, + json: async () => spaProjection, + }); + history.pushState({}, '', '/spa-retry'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(4)); + history.replaceState({}, '', '/spa-retry'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledTimes(5)); + expect(fetchPageBids).toHaveBeenLastCalledWith( + '/_ts/page-bids?path=%2Fspa-retry', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + } finally { + composition.runtime.dispose(); + history.replaceState({}, '', '/'); + } + }); + + it('publishes a committed page-bids winner through the replacement navigation GPT lifecycle', async () => { + const releaseId = 'a'.repeat(64); + const gpt = synchronousGptAdapter(); + const placement = browserSlotPlacement('spa-winner'); + const bid = { + candidateId: 'BBBBBBBBBBBB', + slot: placement.slot, + provider: 'trusted', + upstreamBidId: 'spa-upstream', + cpm: 2, + currency: 'USD' as const, + targeting: { hb_bidder: 'trusted' }, + rendererReservationId: `r1_${'s'.repeat(22)}`, + renderSource: { + type: 'adm' as const, + version: 1 as const, + adm: '
spa winner
', + width: 300, + height: 250, + }, + }; + const spaProjection = { + version: 1, + auction: { + version: 1, + auctionId: 'spa-production', + results: [ + { slot: placement.slot, outcome: 'winner' as const, candidateId: bid.candidateId }, + ], + }, + slots: [placement], + bids: [bid], + }; + const fetchPageBids = vi.fn(async () => ({ ok: true, json: async () => spaProjection })); + const element = document.createElement('div'); + element.id = placement.divId; + document.body.append(element); + let prefix = 0; + const composition = createTestBrowserRuntimeComposition( + { + target: {}, + releaseId, + manifest: { version: 1, releaseId, integrations: [{ id: 'gpt', required: true }] }, + knownIntegrationIds: Object.freeze(['gpt']), + boot: { + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial-empty', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }, + getBindings: () => ({ config: Object.freeze({}), interfaces: Object.freeze({}) }), + kernel: { addAdUnits: vi.fn(), diagnostics: Object.freeze({}), requestAds: vi.fn() }, + }, + { + adapters: { + googletag: gpt.adapter, + messaging: fakeMessagingAdapter(), + prebid: fakePrebidAdapter(), + }, + coreActivations: { correctnessGptListeners: vi.fn() }, + createIdentityIssuerForTest: () => { + prefix += 1; + return createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(prefix); + return target; + }, + }); + }, + pageBidsFetcherForTest: fetchPageBids, + } + ); + + try { + expect(composition.runtime.start()).toBe(true); + expect( + composition.runtime.registerIntegration(createGptIntegrationRegistration(releaseId)) + ).toBe(true); + await expect(composition.runtime.install()).resolves.toMatchObject({ state: 'kernel' }); + history.pushState({}, '', '/spa-production'); + await vi.waitFor(() => expect(fetchPageBids).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(gpt.physicalSlots()).toHaveLength(1)); + const physicalSlot = gpt.physicalSlots()[0]; + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(physicalSlot); + expect(gpt.targetingFor(physicalSlot!)).toEqual( + new Map([ + ['hb_adid', [bid.rendererReservationId]], + ['hb_bidder', ['trusted']], + ]) + ); + expect( + composition.runtimeSessionForTest()?.currentNavigation?.currentAuctionProjection + ).toMatchObject({ auction: { auctionId: 'spa-production' } }); + } finally { + composition.runtime.dispose(); + element.remove(); + } + }); + it('unwinds a lazily-created session when navigation identity generation fails', async () => { const composition = createTestBrowserRuntimeComposition( { @@ -2411,6 +2891,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2451,6 +2932,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2493,6 +2975,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2532,6 +3015,7 @@ describe('browser composition', () => { auctionId: 'spa', results: [{ slot: 'spa-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('spa-slot')], bids: [], }) ).toEqual({ status: 'committed' }); @@ -2549,6 +3033,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2596,6 +3081,9 @@ describe('browser composition', () => { slot: `server-${index}`, })), }, + slots: Array.from({ length: serverCount }, (_, index) => + browserSlotPlacement(`server-${index}`) + ), bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2636,6 +3124,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2677,6 +3166,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, cachePolicy: { @@ -2743,6 +3233,7 @@ describe('browser composition', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2836,6 +3327,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'server-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('server-slot')], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -2990,6 +3482,7 @@ describe('browser composition', () => { auctionId: 'initial', results: [{ slot: 'server-slot', outcome: 'no_bid' }], }, + slots: [browserSlotPlacement('server-slot')], bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, diff --git a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts index 2b9f4816d..bdf19a0f6 100644 --- a/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts +++ b/crates/trusted-server-js/lib/test/composition/maximal-runtime.test.ts @@ -241,6 +241,7 @@ function createMaximalHarness(options: MaximalHarnessOptions = {}) { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, @@ -390,6 +391,7 @@ describe('generated maximal browser runtime transaction', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative: { version: 1, enabled: true, clickGuard: true, renderGuard: false }, diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts index 173e35a01..533db32d9 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -36,6 +36,16 @@ function reservationId(index = 0): string { return `r1_${index.toString(36).padStart(22, 'A')}`; } +function browserSlot(slot: string) { + return { + slot, + gamUnitPath: `/123/${slot}`, + divId: `div-${slot}`, + formats: [[300, 250]] as Array<[number, number]>, + targeting: { pos: slot } as Record, + }; +} + function browserProjection() { const renderer = apsRenderer('fictional-creative-id'); return { @@ -49,6 +59,7 @@ function browserProjection() { { slot: 'slot-3', outcome: 'failed', reason: 'provider_timeout' }, ], }, + slots: [browserSlot('slot-1'), browserSlot('slot-2'), browserSlot('slot-3')], bids: [ { candidateId: candidateId(), @@ -77,6 +88,7 @@ function largeAdmProjection(admLengths: number[]): BrowserAuctionProjectionV1 { candidateId: candidateId(index), })), }, + slots: admLengths.map((_, index) => browserSlot(`slot-${index}`)), bids: admLengths.map((length, index) => ({ candidateId: candidateId(index), slot: `slot-${index}`, @@ -464,22 +476,38 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { } }); + it('requires exact GAM slot definitions in the canonical projection', () => { + const missingSlots = browserProjection() as Record; + delete missingSlots['slots']; + expect(parseBrowserAuctionProjectionV1(missingSlots)).toBeUndefined(); + + const emptySlots = browserProjection(); + emptySlots.slots = []; + expect(parseBrowserAuctionProjectionV1(emptySlots)).toBeUndefined(); + + const valid = browserProjection(); + expect(parseBrowserAuctionProjectionV1(valid)?.slots).toEqual(valid.slots); + }); + it('enforces result and bid count boundaries', () => { expect( parseBrowserAuctionProjectionV1({ version: 1, auction: { version: 1, auctionId: 'auction-empty', results: [] }, + slots: [], bids: [], }) ).toBeDefined(); const atLimit = browserProjection(); atLimit.auction.results = []; + atLimit.slots = []; atLimit.bids = []; for (let index = 0; index < 256; index += 1) { const slot = `slot-${index}`; const id = candidateId(index); atLimit.auction.results.push({ slot, outcome: 'winner', candidateId: id }); + atLimit.slots.push(browserSlot(slot)); atLimit.bids.push({ ...browserProjection().bids[0]!, slot, @@ -508,6 +536,7 @@ describe('auction/parseBrowserAuctionProjectionV1', () => { const valid = browserProjection(); valid.auction.auctionId = 'A'.repeat(128); valid.auction.results[0]!.slot = 'é'.repeat(128); + valid.slots[0]!.slot = 'é'.repeat(128); valid.bids[0]!.slot = 'é'.repeat(128); valid.bids[0]!.upstreamBidId = 'é'.repeat(32); valid.bids[0]!.targeting = Object.fromEntries( @@ -837,6 +866,7 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { const canonical: BrowserAuctionProjectionV1 = { version: 1, auction: projected.auction, + slots: [], bids: projected.bids.map((bid) => ({ ...bid, upstreamBidId: bid.rendererReservationId, @@ -909,7 +939,10 @@ describe('auction/parseTrustedServerAuctionResponseV1', () => { expect(new TextEncoder().encode(JSON.stringify(wire)).byteLength).toBeGreaterThan( MAX_BROWSER_AUCTION_PROJECTION_BYTES ); - expect(parseBrowserAuctionProjectionV1(canonical) !== undefined).toBe(accepted); + expect( + new TextEncoder().encode(JSON.stringify(canonical)).length <= + MAX_BROWSER_AUCTION_PROJECTION_BYTES + ).toBe(accepted); expect(parseTrustedServerAuctionResponseV1(wire) !== undefined).toBe(accepted); } }); diff --git a/crates/trusted-server-js/lib/test/core/index.test.ts b/crates/trusted-server-js/lib/test/core/index.test.ts index a46efa57c..b9cdf8324 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -1,95 +1,100 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -import type { AuctionBidData, AuctionSlot, LegacyTsjsApi } from '../../src/core/types'; - -const ORIGINAL_FETCH = global.fetch; - -describe('core/index', () => { +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TsjsApi } from '../../src/core/types'; + +const RELEASE = 'a'.repeat(64); + +function boot() { + return { + abi: 1, + releaseId: RELEASE, + manifest: { version: 1, releaseId: RELEASE, integrations: [] }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], + bids: [], + }, + creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, + diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, + }; +} + +describe('core production bootstrap', () => { beforeEach(async () => { await vi.resetModules(); document.body.innerHTML = ''; - delete window.tsjs; + delete (window as unknown as { tsjs?: unknown }).tsjs; }); - afterEach(() => { - global.fetch = ORIGINAL_FETCH; - }); - - it('initializes tsjs API with expected surface', async () => { - await import('../../src/core/index'); - const api = window.tsjs as LegacyTsjsApi; - expect(api).toBeDefined(); - expect(typeof api.version).toBe('string'); - expect(Array.isArray(api.que)).toBe(true); + it('commits the exact hard-cutover API and drains the retained preload queue', async () => { + const queued = vi.fn(function (this: TsjsApi) { + expect(this).toBe((window as unknown as { tsjs?: unknown }).tsjs); + }); + const preload = { + boot: boot(), + que: [queued], + _integrationConfig: {}, + renderAdUnit: vi.fn(), + bids: { legacy: true }, + }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await import('../../src/composition/index'); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal.state).toBe('kernel') + ); + + const api = (window as unknown as { tsjs: TsjsApi }).tsjs; + expect(api).toBe(preload); + expect(api.version).toBe('1.0.0'); + expect(api.releaseId).toBe(RELEASE); + expect(api.boot.releaseId).toBe(RELEASE); + expect(api.boot.manifest.releaseId).toBe(RELEASE); + expect(Object.isFrozen(api.boot)).toBe(true); + expect(Object.isFrozen(api.que)).toBe(true); expect(typeof api.addAdUnits).toBe('function'); - expect(typeof api.renderAdUnit).toBe('function'); - expect(typeof api.renderAllAdUnits).toBe('function'); - expect(typeof api.setConfig).toBe('function'); - expect(typeof api.getConfig).toBe('function'); expect(typeof api.requestAds).toBe('function'); + expect(api._registerIntegration({})).toBe(false); + expect(queued).toHaveBeenCalledOnce(); + expect(preload).not.toHaveProperty('_integrationConfig'); + expect(preload).not.toHaveProperty('renderAdUnit'); + expect(preload).not.toHaveProperty('bids'); + expect(preload).not.toHaveProperty('renderAllAdUnits'); + expect(preload).not.toHaveProperty('setConfig'); + expect(preload).not.toHaveProperty('getConfig'); }); - it('defaults adSlots and bids so gated-off pages never see undefined', async () => { - await import('../../src/core/index'); - const api = window.tsjs as LegacyTsjsApi; - expect(api.adSlots).toEqual([]); - expect(api.bids).toEqual({}); + it('starts installation in the combined bundle task without waiting for DOM readiness', async () => { + const readyState = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading'); + const preload = { boot: boot(), que: [], _integrationConfig: {} }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + try { + await import('../../src/composition/index'); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal.state).toBe('kernel') + ); + } finally { + readyState.mockRestore(); + } }); - it('preserves edge-injected adSlots and bids set before the bundle loads', async () => { - window.tsjs = { - adSlots: [{ id: 'pre-injected' } as AuctionSlot], - bids: { 'pre-injected': { hb_pb: '1.00' } } as Record, - } as LegacyTsjsApi; - - await import('../../src/core/index'); - - expect(window.tsjs!.adSlots).toEqual([{ id: 'pre-injected' }]); - expect(window.tsjs!.bids).toEqual({ 'pre-injected': { hb_pb: '1.00' } }); - }); - - it('flushes queued callbacks that existed before initialization', async () => { - const callback = vi.fn(function (this: LegacyTsjsApi) { - expect(this).toBe(window.tsjs); - }); - window.tsjs = { que: [callback] as Array<() => void> } as LegacyTsjsApi; - - await import('../../src/core/index'); - - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('installs queue that executes callbacks immediately with api context', async () => { - await import('../../src/core/index'); - const api = window.tsjs as LegacyTsjsApi; - const fn = vi.fn(); - - api.que.push(fn); - - expect(fn).toHaveBeenCalledTimes(1); - expect(fn.mock.instances[0]).toBe(api); - }); - - it('renders registered ad units using core rendering helpers', async () => { - await import('../../src/core/index'); - const api = window.tsjs as LegacyTsjsApi; - - api.addAdUnits([ - { code: 'slot-1', mediaTypes: { banner: { sizes: [[300, 250]] } } }, - { code: 'slot-2', mediaTypes: { banner: { sizes: [[320, 50]] } } }, - ]); - - api.renderAllAdUnits(); - - expect(document.getElementById('slot-1')?.textContent).toContain('300x250'); - expect(document.getElementById('slot-2')?.textContent).toContain('320x50'); - }); - - it('exposes requestAds from the core request module', async () => { - const { requestAds } = await import('../../src/core/request'); - await import('../../src/core/index'); - const api = window.tsjs as LegacyTsjsApi; - - expect(api.requestAds).toBe(requestAds); + it('fails closed when the transient integration-config transport is not plain data', async () => { + const preload = { + boot: boot(), + que: [], + _integrationConfig: new (class Config {})(), + }; + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await import('../../src/composition/index'); + await vi.waitFor(() => + expect((window as unknown as { tsjs?: TsjsApi }).tsjs?._internal.state).toBe('fallback') + ); + + const api = (window as unknown as { tsjs: TsjsApi }).tsjs; + expect(api._internal).toMatchObject({ state: 'fallback', reason: 'abi_mismatch' }); + expect(api).not.toHaveProperty('diagnostics'); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts index 517e30e55..995637da3 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts @@ -4,8 +4,8 @@ import { FIRST_PARTY_CLICK, MUTATED_CLICK, PROXY_RESPONSE, + activateCreativeRuntime, disposeImportedCreativeModule, - importCreativeModule, } from './helpers'; const ORIGINAL_FETCH = global.fetch; @@ -67,7 +67,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -94,7 +94,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -136,7 +136,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -176,7 +176,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); await Promise.resolve(); @@ -225,7 +225,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('target', '_blank'); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); // Wave 1: creative mutates the link, observer repairs it. anchor.setAttribute('href', MUTATED_CLICK); @@ -317,7 +317,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', 'javascript:evil()'); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); @@ -345,7 +345,7 @@ describe('creative/click.ts', () => { document.body.appendChild(anchor); try { - await importCreativeModule(); + await activateCreativeRuntime(); anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); await vi.runAllTimersAsync(); @@ -372,7 +372,7 @@ describe('creative/click.ts', () => { document.body.appendChild(anchor); try { - await importCreativeModule(); + await activateCreativeRuntime(); anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await Promise.resolve(); await vi.runAllTimersAsync(); diff --git a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts index 1ce8a069c..8e86675fd 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/helpers.ts @@ -17,7 +17,7 @@ export const MUTATED_CLICK = 'https://example.com/landing?bar=2'; export const PROXY_RESPONSE = '/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&bar=2&tstoken=newtoken'; -import type { TsCreativeConfig } from '../../../src/shared/globals'; +import type { CreativeBootV1 } from '../../../src/core/types'; let disposeLastImportedCreative: (() => void) | undefined; @@ -27,19 +27,33 @@ export function disposeImportedCreativeModule(): void { dispose?.(); } -export async function importCreativeModule(config?: TsCreativeConfig): Promise { +export async function activateCreativeRuntime( + config: Partial> = {} +): Promise { disposeImportedCreativeModule(); - const globalRef = globalThis as { - __ts_creative_installed?: boolean; - tsCreativeConfig?: TsCreativeConfig; - }; - delete globalRef.__ts_creative_installed; - if (config) { - globalRef.tsCreativeConfig = config; - } - const creative = await import('../../../src/integrations/creative/index'); - disposeLastImportedCreative = creative.disposeGuards; - if (config) { - delete globalRef.tsCreativeConfig; - } + const [ + { installClickGuard }, + { installDynamicIframeProxy }, + { installDynamicImageProxy }, + startup, + ] = await Promise.all([ + import('../../../src/integrations/creative/click'), + import('../../../src/integrations/creative/iframe'), + import('../../../src/integrations/creative/image'), + import('../../../src/integrations/creative/startup'), + ]); + const boot = Object.freeze({ + version: 1 as const, + enabled: true, + clickGuard: config.clickGuard ?? true, + renderGuard: config.renderGuard ?? false, + }); + const runtime = startup.createCreativeStartup({ + document, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + }); + disposeLastImportedCreative = runtime.activate(boot); + runtime.start(boot); } diff --git a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts index 8319a1602..cef3195b5 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { disposeImportedCreativeModule, importCreativeModule, waitForExpect } from './helpers'; +import { activateCreativeRuntime, disposeImportedCreativeModule, waitForExpect } from './helpers'; describe('creative/iframe.ts', () => { const ORIGINAL_FETCH = global.fetch; @@ -25,7 +25,7 @@ describe('creative/iframe.ts', () => { }); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const iframe = document.createElement('iframe'); iframe.src = 'https://frame.example/widget.html?cb=1'; @@ -44,7 +44,7 @@ describe('creative/iframe.ts', () => { const fetchMock = vi.fn().mockRejectedValue(new Error('network')); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const iframe = document.createElement('iframe'); iframe.src = 'https://frame.example/fallback.html'; diff --git a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts index 525bb66ad..80a93eed7 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { disposeImportedCreativeModule, importCreativeModule, waitForExpect } from './helpers'; +import { activateCreativeRuntime, disposeImportedCreativeModule, waitForExpect } from './helpers'; const ORIGINAL_FETCH = global.fetch; @@ -25,7 +25,7 @@ describe('creative/image.ts', () => { }); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const img = new Image(); img.src = 'https://img.example/pixel.gif?cb=1'; @@ -44,7 +44,7 @@ describe('creative/image.ts', () => { const fetchMock = vi.fn().mockRejectedValue(new Error('network')); global.fetch = fetchMock as unknown as typeof fetch; - await importCreativeModule({ renderGuard: true }); + await activateCreativeRuntime({ renderGuard: true }); const img = new Image(); img.src = 'https://img.example/fallback.png'; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts deleted file mode 100644 index 0ed24dae3..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ /dev/null @@ -1,3932 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; - -import envelope from '../../fixtures/aps-renderer-v1.json'; -import type { - BidRenderSourceV1, - BrowserAuctionBidV1, - GptSlotHandoff, - LegacyTsjsApi, -} from '../../../src/core/types'; - -function apsRenderer() { - const bid = envelope.seatbid[0]!.bid[0]!; - return { - type: 'aps' as const, - version: 1 as const, - accountId: 'example-account-id', - bidId: bid.id, - creativeId: 'fictional-creative-id', - tagType: 'iframe' as const, - creativeUrl: bid.ext.creativeurl, - aaxResponse: btoa(JSON.stringify(envelope)), - width: bid.w, - height: bid.h, - }; -} - -describe('prepareTrustedServerGptTargetingV1', () => { - function projectedBid(renderSource: BidRenderSourceV1): BrowserAuctionBidV1 { - return { - candidateId: 'AAAAAAAAAAAA', - slot: 'slot-1', - provider: 'prebid', - upstreamBidId: 'upstream-bid', - cpm: 1.25, - currency: 'USD', - targeting: { hb_bidder: 'example', hb_pb: '1.25' }, - rendererReservationId: 'r1_AAAAAAAAAAAAAAAAAAAAAA', - renderSource, - }; - } - - it('uses the exact renderer reservation as hb_adid for APS, ADM, and cache', async () => { - const { prepareTrustedServerGptTargetingV1 } = - await import('../../../src/integrations/gpt/index'); - const sources: BidRenderSourceV1[] = [ - apsRenderer(), - { type: 'adm', version: 1, adm: '
ad
', width: 300, height: 250 }, - { - type: 'cache', - version: 1, - cacheId: 'f47447a0-b759-4f2f-9887-af458b79b570', - fetchUrl: 'https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570', - width: 300, - height: 250, - }, - ]; - - for (const source of sources) { - const bid = projectedBid(source); - const targeting = prepareTrustedServerGptTargetingV1(bid); - expect(targeting).toEqual({ - hb_adid: 'r1_AAAAAAAAAAAAAAAAAAAAAA', - hb_bidder: 'example', - hb_pb: '1.25', - }); - expect(bid.targeting).toEqual({ hb_bidder: 'example', hb_pb: '1.25' }); - } - }); - - it('rejects malformed reservations without truncating or falling back to other ids', async () => { - const { prepareTrustedServerGptTargetingV1 } = - await import('../../../src/integrations/gpt/index'); - const malformed = projectedBid({ - type: 'cache', - version: 1, - cacheId: 'f47447a0-b759-4f2f-9887-af458b79b570', - fetchUrl: 'https://cache.example/pbc/v1/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570', - width: 300, - height: 250, - }); - malformed.rendererReservationId = `r1_${'A'.repeat(23)}`; - malformed.upstreamBidId = 'fallback-upstream'; - - expect(prepareTrustedServerGptTargetingV1(malformed)).toBeUndefined(); - expect(malformed.rendererReservationId).toHaveLength(26); - - const prepopulated = projectedBid(apsRenderer()); - prepopulated.targeting.hb_adid = 'forbidden-fallback'; - expect(prepareTrustedServerGptTargetingV1(prepopulated)).toBeUndefined(); - }); -}); - -// Track every 'message' EventListener added to window across the entire test -// file. This lets the installTsRenderBridge suite remove all accumulated -// handlers (registered by each vi.resetModules() + module re-import in the -// installTsAdInit suite) before dispatching its own events. The spy is -// restored and remaining handlers are detached in the afterAll below so the -// patch never leaks past this file. -const allMessageHandlers: EventListener[] = []; -const originalWindowAddEventListener = window.addEventListener.bind(window); -// Plain wrapper, deliberately not vi.spyOn: the render-bridge suite spies on -// window.addEventListener itself, and vi.spyOn on an already-spied method -// returns the same mock instance — its "original" would alias the inner -// implementation and recurse. -(window as { addEventListener: typeof window.addEventListener }).addEventListener = (( - type: string, - handler: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions -) => { - if (type === 'message' && handler) { - allMessageHandlers.push(handler as EventListener); - } - return originalWindowAddEventListener(type, handler, options); -}) as typeof window.addEventListener; - -afterAll(() => { - for (const handler of allMessageHandlers) { - window.removeEventListener('message', handler); - } - allMessageHandlers.length = 0; - (window as { addEventListener: typeof window.addEventListener }).addEventListener = - originalWindowAddEventListener; -}); - -interface SlotRenderEvent { - isEmpty: boolean; - slot: { - getSlotElementId(): string; - getTargeting(key: string): string[]; - }; -} - -// The `Prebid Response` payload the render bridge posts back to the Prebid -// Universal Creative over the message port. -interface PrebidResponseMessage { - message?: string; - adId?: string; - ad?: string; - width?: number; - height?: number; -} - -// `tsjs` is declared globally as the full legacy API (core/types.ts). Omitting -// it from `Window` before re-adding it as a `Partial` avoids the intersection -// that would force every fixture below to satisfy the whole legacy API shape. -type TestGptSlotHandoff = Omit & { formats: number[][] }; -type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { - gptSlotHandoffs?: Record | undefined; -}; -type TestWindow = Omit & { - googletag?: unknown; - apstag?: { setDisplayBids?: () => void }; - tsjs?: TestTsjsApi; -}; - -function appendResponsiveSlotElement( - id: string, - containerHasLayout: boolean, - elementHidden = false, - elementHasLayout = false, - containerVisible = containerHasLayout -): HTMLDivElement { - const container = document.createElement('div'); - container.id = `${id}-container`; - container.dataset.responsiveSlotTest = 'true'; - container.style.display = containerVisible ? 'block' : 'none'; - container.getBoundingClientRect = () => - ({ - width: containerHasLayout ? 320 : 0, - height: containerHasLayout ? 100 : 0, - }) as DOMRect; - - const element = document.createElement('div'); - element.id = id; - element.style.display = elementHidden ? 'none' : 'block'; - element.getBoundingClientRect = () => - ({ - width: elementHasLayout ? 300 : 0, - height: elementHasLayout ? 250 : 0, - }) as DOMRect; - container.appendChild(element); - document.body.appendChild(container); - return element; -} - -function runGptBootstrap(): void { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); -} - -type HandoffImplementation = 'bootstrap' | 'bundle'; - -async function installHandoff(implementation: HandoffImplementation): Promise { - if (implementation === 'bootstrap') { - runGptBootstrap(); - return; - } - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); -} - -describe('installTsAdInit', () => { - beforeEach(() => { - vi.resetModules(); - const tw = window as TestWindow; - delete tw.tsjs; - // jsdom does not implement navigator.sendBeacon; polyfill it for tests - if (!('sendBeacon' in navigator)) { - Object.defineProperty(navigator, 'sendBeacon', { - value: vi.fn().mockReturnValue(true), - writable: true, - configurable: true, - }); - } - // adInit now queries the DOM for div elements by id/prefix — create the - // test div so getElementById and querySelector both resolve correctly. - if (!document.getElementById('div-atf-sidebar')) { - const div = document.createElement('div'); - div.id = 'div-atf-sidebar'; - document.body.appendChild(div); - } - }); - - afterEach(() => { - document.getElementById('div-atf-sidebar')?.remove(); - document.getElementById('div-atf-sidebar-2')?.remove(); - document.getElementById('div-size-hydrated')?.remove(); - document.getElementById('ad-header-0-_r_1_')?.remove(); - document.getElementById("ad'prefix-real")?.remove(); - document.querySelectorAll('[data-responsive-slot-test]').forEach((element) => element.remove()); - }); - - it('reads window.tsjs.bids synchronously and applies bid targeting before refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc-uuid', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/pbc/v1/cache', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const fetchSpy = vi.spyOn(global, 'fetch'); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'abc-uuid'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalled(); - - fetchSpy.mockRestore(); - }); - - it('displays TS-defined slots and does not include them in refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const defineSlotMock = vi.fn().mockReturnValue(mockSlot); - const displayMock = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: defineSlotMock, - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(defineSlotMock).toHaveBeenCalled(); - // GPT requires display() to register/render a freshly-defined slot. - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - // TS-owned slots are displayed, not refreshed (refresh() no-ops for a slot - // that was never displayed). - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('hands a late publisher definition the TS inner-div slot without a second request', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const slots = new Map(); - const requests: string[] = []; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: vi.fn((requestedSlots?: FakeSlot[]) => { - (requestedSlots ?? Array.from(slots.values())).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - if (slots.has(elementId)) return null; - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((elementId: string) => requests.push(elementId)); - const destroySlots = vi.fn(); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - destroySlots, - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - const publisherDefineSlot = googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot; - const publisherDisplay = googletag.display as unknown as (elementId: string) => void; - const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); - publisherSlot.addService(pubads); - publisherDisplay('div-atf-sidebar'); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['div-atf-sidebar']); - expect((window as TestWindow).tsjs!.prevGptSlots).toEqual([]); - - const duplicatePublisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); - expect(duplicatePublisherSlot).toBeNull(); - expect(nativeDefineSlot).toHaveBeenCalledTimes(2); - - (window as TestWindow).tsjs!.adSlots = []; - (window as TestWindow).tsjs!.adInit!(); - expect(destroySlots).not.toHaveBeenCalled(); - }); - - it.each(['slot', 'element'] as const)( - 'hands a hydrated publisher ID off when it displays by %s', - async (displayMode) => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const ssrDiv = document.getElementById('div-atf-sidebar')!; - ssrDiv.id = 'ad-header-0-_R_0_'; - const hydratedId = 'ad-header-0-_r_1_'; - const slots = new Map(); - const requests: string[] = []; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((target: string | Element | FakeSlot) => { - if (typeof target === 'string') { - requests.push(target); - } else if ('getSlotElementId' in target) { - requests.push(target.getSlotElementId()); - } else { - requests.push(target.id); - } - }); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'ad-header-0-', - formats: [[970, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - ssrDiv.id = hydratedId; - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot - )('/123/header', [[970, 250]], hydratedId); - publisherSlot.addService(pubads); - const publisherDisplay = googletag.display as unknown as ( - target: string | Element | FakeSlot - ) => void; - publisherDisplay(displayMode === 'slot' ? publisherSlot : ssrDiv); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['ad-header-0-_R_0_']); - expect((window as TestWindow).tsjs!.gptSlotHandoffs![hydratedId]).toBe( - (window as TestWindow).tsjs!.gptSlotHandoffs!['ad-header-0-_R_0_'] - ); - } - ); - - it('does not transfer an ambiguous hydrated publisher definition', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const firstSlot = makeSlot('ad-header-0-_R_0_'); - const secondSlot = makeSlot('ad-header-0-_R_1_'); - const nativeDefineSlot = vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => - makeSlot(elementId) - ); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => [firstSlot, secondSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - const firstHandoff = { - gamUnitPath: '/123/header', - formats: [[970, 250]], - divIdPrefix: 'ad-header-0-', - slotElementId: 'ad-header-0-_R_0_', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const secondHandoff = { ...firstHandoff, slotElementId: 'ad-header-0-_R_1_' }; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'ad-header-0-_R_0_': firstHandoff, - 'ad-header-0-_R_1_': secondHandoff, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - const defined = ( - (window as TestWindow).googletag as { - defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot; - } - ).defineSlot('/123/header', [[970, 250]], 'ad-header-0-_r_1_'); - - expect(nativeDefineSlot).toHaveBeenCalledOnce(); - expect(defined).not.toBe(firstSlot); - expect(defined).not.toBe(secondSlot); - expect(firstHandoff.publisherClaimed).toBe(false); - expect(secondHandoff.publisherClaimed).toBe(false); - }); - - it('delegates a div-less publisher definition with an unclaimed bundle handoff', async () => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(null); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/fallback', - formats: [[300, 250]], - divIdPrefix: 'div-ts-', - slotElementId: 'div-ts-fallback', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-ts-fallback': handoff }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => - ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId?: string - ) => unknown - )('/123/unrelated', [[728, 90]]) - ).not.toThrow(); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); - expect(handoff.publisherClaimed).toBe(false); - }); - - it('prunes destroyed TS-owned handoffs and their aliases on SPA navigation', async () => { - const slots = new Map< - string, - { - addService(service: unknown): unknown; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - setTargeting(key: string, value: string | string[]): unknown; - } - >(); - const makeSlot = (elementId: string) => ({ - addService: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - setTargeting: vi.fn().mockReturnThis(), - }); - const destroySlots = vi.fn(); - const pubads = { - addEventListener: vi.fn(), - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn((_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - }), - destroySlots, - display: vi.fn(), - enableServices: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - const handoff = (window as TestWindow).tsjs!.gptSlotHandoffs!['div-atf-sidebar']!; - (window as TestWindow).tsjs!.gptSlotHandoffs!['div-atf-sidebar-hydrated'] = handoff; - (window as TestWindow).tsjs!.gptSlotHandoffs!.unrelated = { - ...handoff, - slotElementId: 'div-unrelated', - }; - const ownedSlot = slots.get('div-atf-sidebar')!; - - (window as TestWindow).tsjs!.adSlots = []; - (window as TestWindow).tsjs!.adInit!(); - - expect(destroySlots).toHaveBeenCalledWith([ownedSlot]); - expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ - unrelated: expect.objectContaining({ slotElementId: 'div-unrelated' }), - }); - }); - - it('suppresses a cross-realm element display without throwing', async () => { - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const iframe = document.createElement('iframe'); - document.body.appendChild(iframe); - const crossRealmElement = iframe.contentDocument!.createElement('div'); - crossRealmElement.id = 'div-cross-realm'; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-cross-realm': { - gamUnitPath: '/123/cross-realm', - formats: [[300, 250]], - divIdPrefix: 'div-cross-realm', - slotElementId: 'div-cross-realm', - publisherClaimed: true, - suppressPublisherDisplay: true, - suppressPublisherRefresh: false, - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => - (googletag.display as unknown as (target: Element) => void)(crossRealmElement) - ).not.toThrow(); - expect(nativeDisplay).not.toHaveBeenCalled(); - iframe.remove(); - }); - - it('runs the embedded bootstrap handoff for a hydrated publisher ID', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - }; - const ssrDiv = document.getElementById('div-atf-sidebar')!; - const hydratedId = 'ad-header-0-_r_1_'; - ssrDiv.id = 'ad-header-0-_R_0_'; - const slots = new Map(); - const requests: string[] = []; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - refresh: vi.fn(), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - if (slots.has(elementId) || elementId === hydratedId) return null; - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((target: string | FakeSlot) => { - requests.push(typeof target === 'string' ? target : target.getSlotElementId()); - }); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'ad-header-0-', - formats: [[970, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - (window as TestWindow).tsjs!.adInit!(); - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - ssrDiv.id = hydratedId; - - const googletag = (window as TestWindow).googletag as { - defineSlot(adUnitPath: string, formats: number[][], elementId: string): FakeSlot | null; - display(target: FakeSlot): void; - }; - const publisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); - expect(publisherSlot).not.toBeNull(); - googletag.display(publisherSlot!); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(requests).toEqual(['ad-header-0-_R_0_']); - - const duplicatePublisherSlot = googletag.defineSlot('/123/header', [[970, 250]], ssrDiv.id); - expect(duplicatePublisherSlot).toBeNull(); - expect(nativeDefineSlot).toHaveBeenCalledTimes(2); - }); - - it('delegates a div-less publisher definition with an unclaimed bootstrap handoff', () => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-ts-fallback'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(null); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/fallback', - formats: [[300, 250]], - divIdPrefix: 'div-ts-', - slotElementId: 'div-ts-fallback', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-ts-fallback': handoff }, - }; - - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - - expect(() => - ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId?: string - ) => unknown - )('/123/unrelated', [[728, 90]]) - ).not.toThrow(); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/unrelated', [[728, 90]]); - expect(handoff.publisherClaimed).toBe(false); - }); - - it.each(['bootstrap', 'bundle'] as const)( - 'does not hand a sibling slot to a TS fallback through the %s prefix path', - async (implementation) => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - const siblingSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(siblingSlot); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/mpu', - formats: [[300, 250]], - divIdPrefix: 'div-atf-sidebar', - slotElementId: 'div-atf-sidebar', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const siblingElement = document.createElement('div'); - siblingElement.id = 'div-atf-sidebar-2'; - document.body.appendChild(siblingElement); - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-atf-sidebar': handoff }, - }; - - await installHandoff(implementation); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => typeof siblingSlot - )('/123/mpu', [[300, 250]], siblingElement.id); - (googletag.display as unknown as (target: string) => void)(siblingElement.id); - - expect(publisherSlot).toBe(siblingSlot); - expect(nativeDefineSlot).toHaveBeenCalledOnce(); - expect(nativeDisplay).toHaveBeenCalledWith(siblingElement.id); - expect(handoff.publisherClaimed).toBe(false); - expect(handoff.suppressPublisherDisplay).toBe(false); - } - ); - - it.each(['bootstrap', 'bundle'] as const)( - 'hands a publisher shorthand size to the TS fallback through the %s prefix path', - async (implementation) => { - const fallbackSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-size-original'), - }; - const nativeDefineSlot = vi.fn(); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([fallbackSlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const handoff = { - gamUnitPath: '/123/size', - formats: [[300, 250]], - divIdPrefix: 'div-size-', - slotElementId: 'div-size-original', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const hydratedElement = document.createElement('div'); - hydratedElement.id = 'div-size-hydrated'; - document.body.appendChild(hydratedElement); - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { 'div-size-original': handoff }, - }; - - await installHandoff(implementation); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[], - elementId: string - ) => typeof fallbackSlot - )('/123/size', [300, 250], hydratedElement.id); - (googletag.display as unknown as (target: string) => void)(hydratedElement.id); - - expect(publisherSlot).toBe(fallbackSlot); - expect(nativeDefineSlot).not.toHaveBeenCalled(); - expect(nativeDisplay).not.toHaveBeenCalled(); - expect(handoff.publisherClaimed).toBe(true); - expect(handoff.suppressPublisherDisplay).toBe(false); - } - ); - - it('filters only the claimed slot from the first bootstrap global refresh', () => { - const claimedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-claimed'), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeRefresh = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), - refresh: nativeRefresh, - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-claimed': { - gamUnitPath: '/123/claimed', - formats: [[300, 250]], - divIdPrefix: 'div-claimed', - slotElementId: 'div-claimed', - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }, - }, - }; - - return installHandoff('bootstrap').then(() => { - (pubads.refresh as () => void)(); - - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - expect((window as TestWindow).tsjs!.gptSlotHandoffs!['div-claimed']).toEqual( - expect.objectContaining({ suppressPublisherRefresh: false }) - ); - }); - }); - - it('preserves refresh options while filtering a claimed bootstrap slot', () => { - const claimedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-claimed'), - }; - const unrelatedSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-unrelated'), - }; - const nativeRefresh = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([claimedSlot, unrelatedSlot]), - refresh: nativeRefresh, - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-claimed': { - gamUnitPath: '/123/claimed', - formats: [[300, 250]], - divIdPrefix: 'div-claimed', - slotElementId: 'div-claimed', - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: true, - }, - }, - }; - const refreshOptions = { changeCorrelator: false }; - - return installHandoff('bootstrap').then(() => { - (pubads.refresh as (slots: (typeof claimedSlot)[], options: typeof refreshOptions) => void)( - [claimedSlot, unrelatedSlot], - refreshOptions - ); - - expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot], refreshOptions); - }); - }); - - it('does not transfer an ambiguous hydrated publisher definition through bootstrap', () => { - const firstSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-a'), - }; - const secondSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-prefix-original-b'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(null); - const pubads = { - getSlots: vi.fn().mockReturnValue([firstSlot, secondSlot]), - refresh: vi.fn(), - }; - const firstHandoff = { - gamUnitPath: '/123/prefix', - formats: [[300, 250]], - divIdPrefix: 'div-prefix-', - slotElementId: 'div-prefix-original-a', - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const secondHandoff = { - ...firstHandoff, - slotElementId: 'div-prefix-original-b', - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - 'div-prefix-original-a': firstHandoff, - 'div-prefix-original-b': secondHandoff, - }, - }; - - return installHandoff('bootstrap').then(() => { - const defined = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => null - )('/123/prefix', [[300, 250]], 'div-prefix-hydrated'); - - expect(defined).toBeNull(); - expect(nativeDefineSlot).toHaveBeenCalledOnce(); - expect(firstHandoff.publisherClaimed).toBe(false); - expect(secondHandoff.publisherClaimed).toBe(false); - }); - }); - - it('preserves refresh options while filtering a claimed disabled-load slot', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const slots = new Map(); - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const nativeRefresh = vi.fn(); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - pubads.disableInitialLoad(); - (window as TestWindow).tsjs!.adInit!(); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot - )('/123/atf', [[300, 250]], 'div-atf-sidebar'); - const unrelatedSlot = makeSlot('div-unrelated'); - const refreshOptions = { changeCorrelator: false }; - ( - pubads.refresh as unknown as ( - requestedSlots: FakeSlot[], - options: { changeCorrelator: boolean } - ) => void - )([publisherSlot, unrelatedSlot], refreshOptions); - - expect(nativeRefresh).toHaveBeenLastCalledWith([unrelatedSlot], refreshOptions); - }); - - it('suppresses only the claimed slot from the first disabled-load publisher refresh', async () => { - type FakeSlot = { - addService(service: unknown): FakeSlot; - setTargeting(key: string, value: string | string[]): FakeSlot; - getSlotElementId(): string; - getTargeting(key?: string): string[]; - }; - const slots = new Map(); - const requests: string[] = []; - let initialLoadDisabled = false; - const makeSlot = (elementId: string): FakeSlot => ({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elementId), - getTargeting: vi.fn().mockReturnValue([]), - }); - const pubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn(() => Array.from(slots.values())), - addEventListener: vi.fn(), - refresh: vi.fn((requestedSlots?: FakeSlot[]) => { - (requestedSlots ?? Array.from(slots.values())).forEach((slot) => - requests.push(slot.getSlotElementId()) - ); - }), - disableInitialLoad: vi.fn(() => { - initialLoadDisabled = true; - }), - }; - const nativeDefineSlot = vi.fn( - (_adUnitPath: string, _formats: number[][], elementId: string) => { - const slot = makeSlot(elementId); - slots.set(elementId, slot); - return slot; - } - ); - const nativeDisplay = vi.fn((elementId: string) => { - if (!initialLoadDisabled) requests.push(elementId); - }); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - enableServices: vi.fn(), - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - pubads.disableInitialLoad(); - (window as TestWindow).tsjs!.adInit!(); - - const publisherDefineSlot = googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => FakeSlot; - const publisherDisplay = googletag.display as unknown as (elementId: string) => void; - const publisherRefresh = pubads.refresh as unknown as () => void; - const publisherSlot = publisherDefineSlot('/123/atf', [[300, 250]], 'div-atf-sidebar'); - publisherSlot.addService(pubads); - publisherDisplay('div-atf-sidebar'); - slots.set('div-unrelated', makeSlot('div-unrelated')); - publisherRefresh(); - - expect(nativeDefineSlot).toHaveBeenCalledTimes(1); - expect(nativeDisplay).toHaveBeenCalledTimes(1); - expect(requests.filter((elementId) => elementId === 'div-atf-sidebar')).toHaveLength(1); - expect(requests).toContain('div-unrelated'); - }); - - it('refreshes TS-defined slots when the publisher disabled GPT initial load', async () => { - // With pubads().disableInitialLoad(), display() only registers a freshly - // defined slot — the ad request must come from refresh(). A TS-owned slot - // must therefore be refreshed too, or it renders blank. - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: vi.fn(), - }; - const getConfigMock = vi.fn().mockReturnValue(undefined); - const displayMock = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - // Exercise the wrapper fallback used when the getter has no value. - getConfig: getConfigMock, - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - // Publisher disables initial load — goes through the wrapper the detector - // installed, recording the state on window.tsjs. - mockPubads.disableInitialLoad(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - // The slot is still registered via display(), and additionally refreshed so - // it actually requests an ad under disableInitialLoad(). - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('preserves legacy state in the edge bootstrap when getConfig does not report it', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - const disableInitialLoadMock = vi.fn(); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: nativeRefresh, - disableInitialLoad: disableInitialLoadMock, - }; - const displayMock = vi.fn(); - const getConfigMock = vi.fn().mockReturnValue(undefined); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: getConfigMock, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - runGptBootstrap(); - - mockPubads.disableInitialLoad(); - expect(disableInitialLoadMock).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('tracks setConfig state and re-enabling in the edge bootstrap', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - }; - type InitialLoadConfig = { - disableInitialLoad?: boolean | null; - }; - let effectiveConfig: { disableInitialLoad?: boolean } = {}; - const setConfigMock = vi.fn((config: InitialLoadConfig) => { - if ('disableInitialLoad' in config) { - effectiveConfig = { disableInitialLoad: config.disableInitialLoad === true }; - } - }); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: nativeRefresh, - }; - const displayMock = vi.fn(); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: undefined as undefined | (() => { disableInitialLoad?: boolean }), - setConfig: setConfigMock, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - runGptBootstrap(); - - // Older GPT runtimes may expose setConfig without getConfig. In that case, - // the wrapper tracks explicit initial-load updates directly. - googletag.setConfig({ disableInitialLoad: true }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - googletag.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - googletag.getConfig = vi.fn(() => effectiveConfig); - setConfigMock.mockClear(); - googletag.setConfig({ disableInitialLoad: true }); - expect(setConfigMock).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - - nativeRefresh.mockClear(); - googletag.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - googletag.setConfig({ disableInitialLoad: true }); - googletag.setConfig({ disableInitialLoad: null }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('tracks the effective initial-load state from setConfig', async () => { - // Modern GPT configuration uses googletag.setConfig() rather than the - // legacy pubads().disableInitialLoad() method. TS must detect both forms. - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - type InitialLoadConfig = { - disableInitialLoad?: boolean | null; - singleRequest?: boolean; - }; - let effectiveConfig: { disableInitialLoad?: boolean } = {}; - const setConfigMock = vi.fn((config: InitialLoadConfig) => { - if ('disableInitialLoad' in config) { - effectiveConfig = { disableInitialLoad: config.disableInitialLoad === true }; - } - }); - const disableInitialLoadMock = vi.fn(() => { - effectiveConfig = { disableInitialLoad: true }; - }); - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher has not defined this slot, so TS defines (owns) it. - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - disableInitialLoad: disableInitialLoadMock, - }; - const displayMock = vi.fn(); - const getConfigMock = vi.fn(() => effectiveConfig); - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: undefined as undefined | typeof getConfigMock, - setConfig: setConfigMock, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - installTsAdInit(); - - const gpt = (window as TestWindow).googletag as { - setConfig(config: InitialLoadConfig): void; - }; - gpt.setConfig({ singleRequest: true }); - expect(setConfigMock).toHaveBeenCalledOnce(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).not.toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).not.toHaveBeenCalled(); - - // Fall back to the explicit setConfig value when getConfig is unavailable. - gpt.setConfig({ disableInitialLoad: true }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - gpt.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - googletag.getConfig = getConfigMock; - setConfigMock.mockClear(); - const config = { disableInitialLoad: true, singleRequest: true }; - gpt.setConfig(config); - expect(setConfigMock).toHaveBeenCalledOnce(); - expect(setConfigMock).toHaveBeenLastCalledWith(config); - expect(getConfigMock).toHaveBeenCalledWith('disableInitialLoad'); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - - nativeRefresh.mockClear(); - gpt.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - gpt.setConfig({ disableInitialLoad: null }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - // GPT exposes one effective setting across the modern and legacy APIs. - // A legacy call made after setConfig(false) disables initial load. - mockPubads.disableInitialLoad(); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - - // A later modern call can re-enable initial load after the legacy API. - nativeRefresh.mockClear(); - gpt.setConfig({ disableInitialLoad: false }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - - // Resetting the setting to its default has the same effective result. - mockPubads.disableInitialLoad(); - gpt.setConfig({ disableInitialLoad: null }); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(false); - - (window as TestWindow).tsjs!.adInit!(); - - expect(nativeRefresh).not.toHaveBeenCalled(); - }); - - it('reads initial-load configuration effective before detector installation', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const displayMock = vi.fn(); - const getConfigMock = vi.fn().mockReturnValue({ disableInitialLoad: true }); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: displayMock, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - getConfig: getConfigMock, - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(getConfigMock).toHaveBeenCalledWith('disableInitialLoad'); - expect((window as TestWindow).tsjs!.gptInitialLoadDisabled).toBe(true); - - (window as TestWindow).tsjs!.adInit!(); - - expect(displayMock).toHaveBeenCalledWith('div-atf-sidebar'); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('sets adInitRefreshInProgress only for the duration of the internal refresh', async () => { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - let flagDuringRefresh: boolean | undefined; - const mockPubads = { - enableSingleRequest: vi.fn(), - // Publisher-owned slot reused by TS, so it goes through refresh() (which - // carries the bypass flag) rather than display(). - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(() => { - flagDuringRefresh = (window as TestWindow).tsjs!.adInitRefreshInProgress; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.refresh).toHaveBeenCalled(); - expect(flagDuringRefresh).toBe(true); - expect((window as TestWindow).tsjs!.adInitRefreshInProgress).toBe(false); - }); - - it('clears stale TS targeting from previously touched slots when the new route has no TS slots', async () => { - const clearTargeting = vi.fn().mockReturnThis(); - const staleSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting, - getSlotElementId: vi.fn().mockReturnValue('div-old-route'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([staleSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - // New route has no matching TS slots. - adSlots: [], - bids: {}, - // Previous route touched the publisher-owned slot on div-old-route. - divToSlotId: { 'div-old-route': 'old_slot' }, - prevSlotTargetingKeys: { 'div-old-route': ['pos'] }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('pos'); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); - expect((window as TestWindow).tsjs!.prevSlotTargetingKeys).toEqual({}); - }); - - it('does not enable GPT services when the page-bids response has no slots', async () => { - // A gated page-bids response returns no slots. With nothing to display or - // refresh and services not already enabled, adInit() must not call - // enableSingleRequest()/enableServices() and activate the publisher's GPT - // services on a consent-denied or kill-switched navigation. - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const enableServices = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices, - }; - (window as TestWindow).tsjs = { - adSlots: [], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); - expect(enableServices).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.servicesEnabled).toBeFalsy(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - }); - - it('keeps the GAM path when a bid carries inline adm (adInit does not inject)', async () => { - const slotEl = document.getElementById('div-atf-sidebar')!; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['debug-uuid']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const destroySlots = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - destroySlots, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '0.20', - hb_bidder: 'mocktioneer', - hb_adid: 'debug-uuid', - adm: '
Inline creative
', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(slotEl.innerHTML).toBe(''); - expect(destroySlots).not.toHaveBeenCalledWith([mockSlot]); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '0.20'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - }); - - // Helper: full adInit setup for a single slot whose bid carries an iframe adm. - // `debugBid` toggles the per-bid `debug_bid` field that gates the testing bypass. - async function fireSlotRenderWithAdm(debugBid: boolean): Promise { - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - adm: '', - ...(debugBid ? { debug_bid: { slot_id: 'atf_sidebar_ad' } } : {}), - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - // A pre-existing GAM iframe; the bypass, if it runs, rewrites its src. - const slotEl = document.getElementById('div-atf-sidebar')!; - const gamIframe = document.createElement('iframe'); - gamIframe.src = 'about:blank'; - slotEl.appendChild(gamIframe); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - return gamIframe; - } - - it('does not run the GAM-replace bypass without debug_bid (production)', async () => { - const gamIframe = await fireSlotRenderWithAdm(false); - // No debug_bid ⇒ testing bypass is off; the render bridge handles the creative - // and GAM stays in the loop, so the GAM iframe src is untouched. - expect(gamIframe.src).toBe('about:blank'); - }); - - it('runs the GAM-replace bypass when debug_bid is present (testing)', async () => { - const gamIframe = await fireSlotRenderWithAdm(true); - // debug_bid present ⇒ inject_adm_for_testing on ⇒ direct GAM replace fires, - // rewriting the iframe to the creative URL from the adm. - expect(gamIframe.src).toBe('https://cdn.example/creative.html'); - }); - - it('does not fire win/billing beacons from slotRenderEnded targeting alone', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - capturedListener!({ isEmpty: false, slot: mockSlot }); - - expect(beaconSpy).not.toHaveBeenCalled(); - - // GPT slot targeting is request state, not proof that the TS creative - // rendered. A repeated non-empty render must still not bill from this path. - capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).not.toHaveBeenCalled(); - - beaconSpy.mockRestore(); - }); - - it('does not fire beacons for an APS-style bid that carries no hb_adid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - nurl: 'https://aps/win', - burl: 'https://aps/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(capturedListener).toBeDefined(); - - // Without an hb_adid to confirm the rendered creative is ours, a non-empty - // render is not proof of a TS win: the slot could have been filled by other - // GAM demand. The beacon must not fire, so we never over-report billing. - capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).not.toHaveBeenCalled(); - - beaconSpy.mockRestore(); - }); - - it('does not fire nurl/burl when bid did not win GAM line item', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlotNoMatch = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlotNoMatch]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); - - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('does not fire beacons for slotRenderEnded on slots not owned by TS', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: SlotRenderEvent) => void) | undefined; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue(['abc']), - }; - const arenaSlot = { - getSlotElementId: () => 'arena-owned-div', - getTargeting: () => [], - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn; - }), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - capturedListener!({ isEmpty: false, slot: arenaSlot }); - - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('does not call native apstag for a Trusted Server APS renderer winner', async () => { - const setDisplayBidsSpy = vi.fn(); - (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - hb_adid: envelope.seatbid[0]!.bid[0]!.id, - renderer: apsRenderer(), - }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - expect((window as TestWindow).apstag).toEqual({ setDisplayBids: setDisplayBidsSpy }); - - delete (window as TestWindow).apstag; - }); - - it('does not call apstag.setDisplayBids when hb_bidder is not aps', async () => { - const setDisplayBidsSpy = vi.fn(); - (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; - - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([mockSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' }, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - - delete (window as TestWindow).apstag; - }); - - it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { - const emptyTestSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([emptyTestSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - }), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(mockPubads.refresh).toHaveBeenCalled(); - }); - - it.each([ - { implementation: 'runtime', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'runtime', activeIndexes: [], selectedIndex: null }, - { implementation: 'runtime', activeIndexes: [], elementLayoutIndexes: [1], selectedIndex: 1 }, - { implementation: 'runtime', activeIndexes: [0, 2], selectedIndex: null }, - { - implementation: 'runtime', - activeIndexes: [2, 3], - hiddenElementIndexes: [2], - selectedIndex: 3, - }, - { - implementation: 'runtime', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [2], - selectedIndex: 2, - }, - { implementation: 'runtime', activeIndexes: [2], divId: '', selectedIndex: null }, - { implementation: 'bootstrap', activeIndexes: [2], publisherOwned: true, selectedIndex: 2 }, - { implementation: 'bootstrap', activeIndexes: [], selectedIndex: null }, - { implementation: 'bootstrap', activeIndexes: [], elementLayoutIndexes: [1], selectedIndex: 1 }, - { implementation: 'bootstrap', activeIndexes: [0, 2], selectedIndex: null }, - { - implementation: 'bootstrap', - activeIndexes: [2, 3], - hiddenElementIndexes: [2], - selectedIndex: 3, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [2], - selectedIndex: 2, - }, - { implementation: 'bootstrap', activeIndexes: [2], divId: '', selectedIndex: null }, - ] as const)( - '$implementation resolves responsive matches $activeIndexes to $selectedIndex', - async (testCase) => { - const { implementation, activeIndexes, selectedIndex } = testCase; - const hiddenElementIndexes = - 'hiddenElementIndexes' in testCase ? testCase.hiddenElementIndexes : []; - const elementLayoutIndexes = - 'elementLayoutIndexes' in testCase ? testCase.elementLayoutIndexes : []; - const visibleContainerIndexes = - 'visibleContainerIndexes' in testCase ? testCase.visibleContainerIndexes : activeIndexes; - const divId = 'divId' in testCase ? testCase.divId : 'ad-responsive-'; - const publisherOwned = 'publisherOwned' in testCase && testCase.publisherOwned; - const elements = ['a', 'b', 'c', 'd'].map((suffix, index) => - appendResponsiveSlotElement( - `ad-responsive-${suffix}`, - (activeIndexes as readonly number[]).includes(index), - (hiddenElementIndexes as readonly number[]).includes(index), - (elementLayoutIndexes as readonly number[]).includes(index), - (visibleContainerIndexes as readonly number[]).includes(index) - ) - ); - const selectedElement = selectedIndex === null ? undefined : elements[selectedIndex]; - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(selectedElement?.id ?? elements[0]!.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const nativeRefresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue(publisherOwned ? [mockSlot] : []), - addEventListener: vi.fn(), - refresh: nativeRefresh, - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const nativeDisplay = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'responsive_slot', - gam_unit_path: '/123/responsive', - div_id: divId, - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - if (selectedElement) { - if (publisherOwned) { - expect(defineSlot).not.toHaveBeenCalled(); - expect(nativeRefresh).toHaveBeenCalledWith([mockSlot]); - } else { - expect(defineSlot).toHaveBeenCalledWith( - '/123/responsive', - [[300, 250]], - selectedElement.id - ); - expect(nativeDisplay).toHaveBeenCalledWith(selectedElement.id); - } - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({ - [selectedElement.id]: 'responsive_slot', - }); - } else { - expect(defineSlot).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs!.divToSlotId).toEqual({}); - } - } - ); - - it('resolves dynamic div prefixes without interpolating div_id into a CSS selector', async () => { - const dynamicDiv = document.createElement('div'); - dynamicDiv.id = "ad'prefix-real"; - document.body.appendChild(dynamicDiv); - - const dynamicSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue("ad'prefix-real"), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([dynamicSlot]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(dynamicSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'dynamic_slot', - gam_unit_path: '/123/dynamic', - div_id: "ad'prefix-", - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(mockPubads.refresh).toHaveBeenCalledWith([dynamicSlot]); - }); -}); - -describe('parseCachedBid', () => { - async function parseCachedBid(body: string) { - const mod = await import('../../../src/integrations/gpt/index'); - return mod.parseCachedBid(body); - } - - it('decodes adm, dimensions, and price from a PBS Cache bid object', async () => { - const bid = await parseCachedBid( - JSON.stringify({ adm: '
cached
', w: 300, h: 250, price: 1.23 }) - ); - expect(bid).toEqual({ adm: '
cached
', width: 300, height: 250, price: 1.23 }); - }); - - it('accepts width/height as an alternate dimension spelling', async () => { - const bid = await parseCachedBid( - JSON.stringify({ adm: '
cached
', width: 728, height: 90 }) - ); - expect(bid?.width).toBe(728); - expect(bid?.height).toBe(90); - }); - - it('treats zero dimensions as absent so the caller falls back', async () => { - const bid = await parseCachedBid(JSON.stringify({ adm: '
cached
', w: 0, h: 0 })); - expect(bid?.width).toBeUndefined(); - expect(bid?.height).toBeUndefined(); - }); - - it('treats a non-JSON body as raw creative markup with no metadata', async () => { - const bid = await parseCachedBid('
raw
'); - expect(bid).toEqual({ adm: '
raw
' }); - }); - - it('returns undefined when the JSON payload carries no usable adm', async () => { - expect(await parseCachedBid(JSON.stringify({ w: 300, h: 250 }))).toBeUndefined(); - expect(await parseCachedBid(' ')).toBeUndefined(); - }); -}); - -describe('installTsRenderBridge', () => { - let fetchStub: ReturnType; - - beforeEach(() => { - vi.resetModules(); - // Remove ALL accumulated 'message' handlers from previous test module imports - // to prevent stale bridge listeners from intercepting our test event. - for (const handler of allMessageHandlers) { - window.removeEventListener('message', handler); - } - allMessageHandlers.length = 0; - - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - if (typeof navigator.sendBeacon !== 'function') { - Object.defineProperty(navigator, 'sendBeacon', { - value: vi.fn().mockReturnValue(true), - writable: true, - configurable: true, - }); - } - - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'test-cache-uuid', - hb_bidder: 'kargo', - hb_pb: '1.50', - hb_cache_host: 'openads.example.com', - hb_cache_path: '/cache', - nurl: 'https://ssp.example/win', - burl: 'https://ssp.example/bill', - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - }; - }); - - afterEach(() => { - vi.unstubAllGlobals(); - document.getElementById('div-header')?.remove(); - delete (window as TestWindow).tsjs; - }); - - function createTrustedSlotIframe(divId = 'div-header'): Window { - const slot = document.createElement('div'); - slot.id = divId; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - return iframe.contentWindow!; - } - - async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - return bridgeListener!; - } - - it('serves one exact APS dynamic-renderer response without cache fetches or beacons', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - hb_pb: '1.23', - renderer, - // These must not be used even if unexpected legacy fields coexist. - nurl: 'https://notify.example/win', - burl: 'https://notify.example/bill', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }; - - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (message: string) => portMessages.push(message) }; - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent; - - bridgeListener(event); - bridgeListener(event); - - expect(stopSpy).toHaveBeenCalledTimes(2); - expect(fetchStub).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - // Server-rendered APS descriptors are reusable: GAM can issue repeated - // Universal Creative requests for the same winning ad ID. - expect(portMessages).toHaveLength(2); - const response = JSON.parse(portMessages[0]!) as Record; - expect(Object.keys(response).sort()).toEqual( - [ - 'adId', - 'apsRenderer', - 'height', - 'message', - 'renderer', - 'rendererUrl', - 'rendererVersion', - 'width', - ].sort() - ); - expect(response).toEqual({ - message: 'Prebid Response', - adId: renderer.bidId, - renderer: expect.stringContaining('window.render=function'), - rendererVersion: 4, - rendererUrl: new URL('/integrations/aps/renderer', window.location.origin).href, - apsRenderer: renderer, - width: 300, - height: 250, - }); - expect(String(response.renderer)).not.toContain(renderer.accountId); - expect(String(response.renderer)).not.toContain(renderer.aaxResponse); - - // Universal Creative's dynamic-renderer path evaluates the returned static - // source and calls window.render(response, helper, targetWindow). Consume - // the exact bridge response through that deployed protocol shape. - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; - }; - window.eval(String(response.renderer)); - try { - const rendered = dynamicWindow.render!(response, undefined, window); - const outerFrame = document.querySelector( - 'iframe[src*="/integrations/aps/renderer#tsaps="]' - )!; - expect(outerFrame).not.toBeNull(); - expect(outerFrame.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const rendererPost = vi.spyOn(outerFrame.contentWindow!, 'postMessage'); - outerFrame.dispatchEvent(new Event('load')); - const sent = rendererPost.mock.calls[0]![0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: outerFrame.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - outerFrame.remove(); - } finally { - delete dynamicWindow.render; - } - beaconSpy.mockRestore(); - }); - - it('resizes only the authenticated collapsed 1x1 creative shell after responding', async () => { - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'collapsed-inline-ad-id', - hb_bidder: 'fictional', - hb_pb: '1.23', - adm: '
fictional creative
', - w: 300, - h: 250, - }; - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const slot = document.getElementById('div-header')!; - const selectedFrame = slot.querySelector('iframe')!; - slot.style.width = '1px'; - slot.style.height = '1px'; - selectedFrame.width = '1'; - selectedFrame.height = '1'; - selectedFrame.style.width = '1px'; - selectedFrame.style.height = '1px'; - - const siblingSlot = document.createElement('div'); - siblingSlot.style.width = '1px'; - siblingSlot.style.height = '1px'; - const siblingFrame = document.createElement('iframe'); - siblingFrame.width = '1'; - siblingFrame.height = '1'; - siblingFrame.style.width = '1px'; - siblingFrame.style.height = '1px'; - siblingSlot.appendChild(siblingFrame); - document.body.appendChild(siblingSlot); - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'collapsed-inline-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(selectedFrame.style.width).toBe('300px'); - expect(selectedFrame.style.height).toBe('250px'); - expect(slot.style.width).toBe('300px'); - expect(slot.style.height).toBe('250px'); - expect(siblingFrame.style.width).toBe('1px'); - expect(siblingFrame.style.height).toBe('1px'); - } finally { - siblingSlot.remove(); - } - }); - - it('does not partially resize when the authenticated wrapper is already expanded', async () => { - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'expanded-wrapper-ad-id', - hb_bidder: 'fictional', - hb_pb: '1.23', - adm: '
fictional creative
', - w: 300, - h: 250, - }; - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const slot = document.getElementById('div-header')!; - const frame = slot.querySelector('iframe')!; - slot.style.width = '2px'; - slot.style.height = '1px'; - frame.width = '1'; - frame.height = '1'; - frame.style.width = '1px'; - frame.style.height = '1px'; - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'expanded-wrapper-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(frame.style.width).toBe('1px'); - expect(frame.style.height).toBe('1px'); - expect(slot.style.width).toBe('2px'); - expect(slot.style.height).toBe('1px'); - }); - - it('serves a registered Prebid APS renderer when its generated ad ID differs from the APS bid ID', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'prebid-generated-ad-id'; - const markWinner = vi.fn(); - const markRendered = vi.fn(); - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner, - markRendered, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent; - - bridgeListener(event); - const foreignIframe = document.createElement('iframe'); - document.body.appendChild(foreignIframe); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledTimes(2); - expect(portMessages).toHaveLength(1); - expect(markWinner).toHaveBeenCalledTimes(1); - expect(markRendered).toHaveBeenCalledTimes(1); - expect(JSON.parse(portMessages[0]!)).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - width: renderer.width, - height: renderer.height, - }) - ); - expect(renderer.bidId).not.toBe(prebidAdId); - expect((window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId]).toBeUndefined(); - expect(fetchStub).not.toHaveBeenCalled(); - foreignIframe.remove(); - }); - - it('still serves the APS renderer when markWinner throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-winner-ad-id'; - const markWinner = vi.fn(() => { - throw new Error('fictional markWinner failure'); - }); - const markRendered = vi.fn(); - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner, - markRendered, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0]!)).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - }) - ); - expect(markWinner).toHaveBeenCalledTimes(1); - expect(markRendered).toHaveBeenCalledTimes(1); - }); - - it('still completes the APS render when markRendered throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-rendered-ad-id'; - const markWinner = vi.fn(); - const markRendered = vi.fn(() => { - throw new Error('fictional markRendered failure'); - }); - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner, - markRendered, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0]!)).toEqual( - expect.objectContaining({ - message: 'Prebid Response', - adId: prebidAdId, - apsRenderer: renderer, - }) - ); - expect(markWinner).toHaveBeenCalledTimes(1); - expect(markRendered).toHaveBeenCalledTimes(1); - }); - - it('prunes expired consumed APS renderer IDs', async () => { - vi.useFakeTimers(); - try { - const renderer = apsRenderer(); - const prebidAdId = 'expiring-consumed-ad-id'; - const start = Date.now(); - const firstMarkWinner = vi.fn(); - const firstMarkRendered = vi.fn(); - const secondMarkWinner = vi.fn(); - const secondMarkRendered = vi.fn(); - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: start, - expiresAt: start + 60_000, - markWinner: firstMarkWinner, - markRendered: firstMarkRendered, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const portMessages: string[] = []; - const sendRequest = (): void => { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - }; - - sendRequest(); - vi.advanceTimersByTime(60_001); - (window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId] = { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner: secondMarkWinner, - markRendered: secondMarkRendered, - }; - sendRequest(); - - expect(portMessages).toHaveLength(2); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(2); - expect(firstMarkWinner).toHaveBeenCalledTimes(1); - expect(firstMarkRendered).toHaveBeenCalledTimes(1); - expect(secondMarkWinner).toHaveBeenCalledTimes(1); - expect(secondMarkRendered).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('fails closed when consumed APS renderer tombstones reach capacity', async () => { - const renderer = apsRenderer(); - const capacity = 256; - const callbacks = Array.from({ length: capacity + 1 }, () => ({ - markWinner: vi.fn(), - markRendered: vi.fn(), - })); - const entries = Object.fromEntries( - callbacks.map((lifecycle, index) => [ - `capacity-ad-${index}`, - { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - ...lifecycle, - }, - ]) - ); - (window as TestWindow).tsjs!.apsPrebidRenderers = entries; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const portMessages: string[] = []; - const sendRequest = (adId: string): void => { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - }; - - for (let index = 0; index < capacity; index += 1) { - sendRequest(`capacity-ad-${index}`); - } - sendRequest(`capacity-ad-${capacity}`); - sendRequest('capacity-ad-0'); - - expect(portMessages).toHaveLength(capacity); - expect(callbacks[capacity]!.markWinner).not.toHaveBeenCalled(); - expect(callbacks[capacity]!.markRendered).not.toHaveBeenCalled(); - expect(entries[`capacity-ad-${capacity}`]).toBeDefined(); - expect(callbacks[0]!.markWinner).toHaveBeenCalledTimes(1); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(capacity + 2); - }); - - it('does not expose a registered Prebid APS renderer to another slot iframe', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'prebid-generated-ad-id'; - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markWinner: vi.fn(), - markRendered: vi.fn(), - }, - }; - - const footer = document.createElement('div'); - footer.id = 'div-footer'; - const foreignIframe = document.createElement('iframe'); - footer.appendChild(foreignIframe); - document.body.appendChild(footer); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).toHaveBeenCalledTimes(1); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId]).toBeDefined(); - footer.remove(); - }); - - it('drops an expired Prebid APS renderer without claiming the creative request', async () => { - const prebidAdId = 'expired-prebid-ad-id'; - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer: apsRenderer(), - registeredAt: Date.now() - 61_000, - expiresAt: Date.now() - 1_000, - markWinner: vi.fn(), - markRendered: vi.fn(), - }, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId]).toBeUndefined(); - }); - - it('validates APS data before claiming the Prebid request', async () => { - const renderer = { ...apsRenderer(), aaxResponse: 'invalid' }; - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('accepts an APS request from a dynamic slot root resolved from its configured prefix', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs!.adSlots![0]!.div_id = 'div-header-'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe('div-header-dynamic'); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(portMessages).toHaveLength(1); - document.getElementById('div-header-dynamic')?.remove(); - }); - - it('does not let an overlapping slot prefix claim another slot iframe', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs!.adSlots!.push({ - id: 'homepage_header_mobile', - formats: [[320, 50]], - gam_unit_path: '/a/b/mobile', - div_id: 'div-header-mobile', - targeting: {}, - }); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe('div-header-mobile'); - const portMessages: string[] = []; - const stopSpy = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - document.getElementById('div-header-mobile')?.remove(); - }); - - it('ignores an APS ad ID requested by another configured slot', async () => { - const renderer = apsRenderer(); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: renderer.bidId, - hb_bidder: 'aps', - renderer, - }; - (window as TestWindow).tsjs!.adSlots!.push({ - id: 'homepage_footer', - formats: [[300, 250]], - gam_unit_path: '/a/b/footer', - div_id: 'div-footer', - targeting: {}, - }); - const footer = document.createElement('div'); - footer.id = 'div-footer'; - const foreignIframe = document.createElement('iframe'); - footer.appendChild(foreignIframe); - document.body.appendChild(footer); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: renderer.bidId }), - ports: [{ postMessage: (message: string) => portMessages.push(message) }], - source: foreignIframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect(fetchStub).not.toHaveBeenCalled(); - footer.remove(); - }); - - it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const mockAd = '
Test Creative
'; - // PBS Cache (returnCreative=false) returns the cached bid as a JSON object; - // the creative lives under `adm`, not as the raw response body. The bridge - // must parse it and forward `adm`, mirroring the Prebid Universal Creative. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: mockAd, width: 728, height: 90 })), - } as Response); - - // Capture the bridge's 'message' listener at module-init time. - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); // Restore only addEventListener — fetchStub must stay stubbed - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - // Dispatch the fake event — bridge listener fires synchronously, then runs - // fire-and-forget fetch().then() chains asynchronously. - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - // Flush microtasks so the fetch mock resolves and .then chains fire. - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledWith( - 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } - ); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - expect(parsed.message).toBe('Prebid Response'); - expect(parsed.adId).toBe('test-cache-uuid'); - expect(parsed.ad).toBe(mockAd); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('declines to render when the PBS Cache response carries no adm', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // A returnCreative=false JSON entry with no `adm` (VAST-only, or malformed). - // The bridge must NOT forward the serialized bid document to PUC. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ width: 728, height: 90 })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // TS owns the adId so Prebid is still stopped, but with nothing renderable - // the bridge sends no Prebid Response and fires no win/billing beacons. - expect(fetchStub).toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('renders a non-JSON PBS Cache body as raw creative markup', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const rawAd = '
Raw Cached Creative
'; - // Backward compatibility: a cache that returns the creative markup directly - // (not a JSON bid object) is still rendered as-is. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(rawAd), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - expect(parsed.ad).toBe(rawAd); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('sizes a PBS Cache render from the cached bid dimensions', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // Cached bid is 300x250 while the slot's first format is 728x90 (from the - // default setup). The response must use the cached dimensions. - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - beaconSpy.mockRestore(); - }); - - it('expands ${AUCTION_PRICE} from the cached bid price before responding', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - fetchStub.mockResolvedValue({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - adm: 'go', - price: 2.5, - }) - ), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - expect(parsed.ad).toContain('p=2.5'); - expect(parsed.ad).not.toContain('${AUCTION_PRICE}'); - beaconSpy.mockRestore(); - }); - - it('fetches PBS Cache once when two same-adId messages race before the fetch resolves', async () => { - // Concurrent render double-fire guard: two 'Prebid Request' messages for the - // same adId can arrive before the first cache fetch settles. The in-flight - // `renderingAdIds` gate must collapse them to a single fetch — the persistent - // firedBeacons dedup only engages after a fetch resolves, so it cannot stop - // the second fetch on its own. Deferring the fetch keeps both messages in the - // window where only the in-flight gate can prevent the duplicate. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const mockAd = '
Test Creative
'; - let resolveFetch: (value: Response) => void = () => {}; - fetchStub.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - - const bridgeListener = await captureBridgeListener(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - const dispatch = (): unknown => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - // Both messages dispatched before the deferred fetch resolves. - dispatch(); - dispatch(); - - // The second message hit the in-flight gate — only one fetch launched. - expect(fetchStub).toHaveBeenCalledTimes(1); - - // Resolve the single fetch and flush its .then chain. - resolveFetch({ ok: true, text: () => Promise.resolve(mockAd) } as Response); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(portMessages).toHaveLength(1); - // A single render still fires both win and billing beacons exactly once. - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('does not let one slot block a PBS Cache render for another slot sharing an adId', async () => { - // The in-flight guard must be scoped to the requesting slot, not the shared - // adId: two distinct slots sharing one hb_adid must each fetch and render. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - // Deferred fetch that stays pending, so both messages are in flight when we - // assert the launched-fetch count. - fetchStub.mockReturnValue(new Promise(() => {})); - (window as TestWindow).tsjs = { - bids: { - slot_a: { - hb_adid: 'shared-uuid', - hb_bidder: 'ix', - hb_pb: '1.00', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - slot_b: { - hb_adid: 'shared-uuid', - hb_bidder: 'ix', - hb_pb: '1.00', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - }, - adSlots: [ - { - id: 'slot_a', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a', - div_id: 'div-a', - targeting: {}, - }, - { - id: 'slot_b', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a', - div_id: 'div-b', - targeting: {}, - }, - ], - }; - - const bridgeListener = await captureBridgeListener(); - - const mkIframe = (divId: string): Window => { - const slot = document.createElement('div'); - slot.id = divId; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - return iframe.contentWindow!; - }; - const sourceA = mkIframe('div-a'); - const sourceB = mkIframe('div-b'); - - try { - for (const source of [sourceA, sourceB]) { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'shared-uuid' }), - ports: [{ postMessage: () => {} }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - } - - // Each slot launches its own fetch — the shared adId does not cross-block. - expect(fetchStub).toHaveBeenCalledTimes(2); - } finally { - document.getElementById('div-a')?.remove(); - document.getElementById('div-b')?.remove(); - beaconSpy.mockRestore(); - } - }); - - it('serves inline adm without fetching PBS Cache even when cache coords are present', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const inlineAdm = '
Inline Creative
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'debug-adid', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - // Production shape: cache coordinates ARE present, but the bridge must - // prefer the local inline adm and skip the PBS Cache fetch. - hb_cache_host: 'cache.example.com', - hb_cache_path: '/pbc/v1/cache', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: inlineAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - }; - - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const addSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - origAdd( - type, - handler as EventListener, - opts as boolean | AddEventListenerOptions | undefined - ); - } - ); - await import('../../../src/integrations/gpt/index'); - addSpy.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - bridgeListener!( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).not.toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - expect(parsed.message).toBe('Prebid Response'); - expect(parsed.adId).toBe('debug-adid'); - expect(parsed.ad).toBe(inlineAdm); - expect(parsed.width).toBe(728); - expect(parsed.height).toBe(90); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('sizes the inline response from the winning bid, not the first slot format', async () => { - // Multi-size slot whose winner is the SECOND configured format. Sizing from - // slot.formats[0] would render the 300x250 winner in a 728x90 box. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const winnerAdm = '
Winner 300x250
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'winner-adid', - hb_bidder: 'ix', - hb_pb: '2.00', - w: 300, - h: 250, - adm: winnerAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [ - [728, 90], - [300, 250], - ] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - ], - }; - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'winner-adid' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - } finally { - beaconSpy.mockRestore(); - } - }); - - it('resolves the requesting slot bid when two slots share one hb_adid', async () => { - // Duplicate hb_adid across slots: PBS Cache is absent, so hb_adid falls back - // to a creative id that a bidder reuses across slots. The bridge must resolve - // the bid by the requesting slot, not the first bid whose hb_adid matches — - // otherwise every slot but the first renders blank. - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const headerAdm = '
Header Creative
'; - const inContentAdm = '
In-Content Creative
'; - (window as TestWindow).tsjs = { - bids: { - homepage_header: { - hb_adid: 'shared-creative-id', - hb_bidder: 'ix', - hb_pb: '0.53', - adm: headerAdm, - }, - homepage_in_content: { - hb_adid: 'shared-creative-id', - hb_bidder: 'ix', - hb_pb: '0.40', - adm: inContentAdm, - }, - }, - adSlots: [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-header', - targeting: {}, - }, - { - id: 'homepage_in_content', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-in-content', - targeting: {}, - }, - ], - }; - - const bridgeListener = await captureBridgeListener(); - const stopSpy = vi.fn(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - - // Iframe belongs to the SECOND slot, whose bid is not the first hb_adid match. - const slot = document.createElement('div'); - slot.id = 'div-in-content'; - const iframe = document.createElement('iframe'); - slot.appendChild(iframe); - document.body.appendChild(slot); - const source = iframe.contentWindow!; - - try { - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'shared-creative-id' }), - ports: [fakePort], - source, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(portMessages).toHaveLength(1); - - const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; - // The requesting slot's own creative and dimensions, not the first match's. - expect(parsed.ad).toBe(inContentAdm); - expect(parsed.width).toBe(300); - expect(parsed.height).toBe(250); - } finally { - slot.remove(); - beaconSpy.mockRestore(); - } - }); - - it('falls back to keepalive fetch when sendBeacon is unavailable', async () => { - const originalSendBeacon = navigator.sendBeacon; - Object.defineProperty(navigator, 'sendBeacon', { - value: undefined, - writable: true, - configurable: true, - }); - - try { - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'debug-no-beacon', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: '
Debug Creative
', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-no-beacon' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/win', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/bill', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - } finally { - Object.defineProperty(navigator, 'sendBeacon', { - value: originalSendBeacon, - writable: true, - configurable: true, - }); - } - }); - - it('falls back to keepalive fetch when sendBeacon rejects the payload', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(false); - (window as TestWindow).tsjs!.bids!.homepage_header = { - hb_adid: 'debug-rejected-beacon', - hb_bidder: 'mocktioneer', - hb_pb: '0.20', - nurl: 'https://debug.example/win', - burl: 'https://debug.example/bill', - adm: '
Debug Creative
', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const source = createTrustedSlotIframe(); - const event = Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-rejected-beacon' }), - ports: [fakePort], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent; - - bridgeListener(event); - - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/bill'); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/win', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - expect(fetchStub).toHaveBeenCalledWith('https://debug.example/bill', { - method: 'POST', - keepalive: true, - mode: 'no-cors', - }); - - bridgeListener(event); - expect(fetchStub).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('ignores message when adId does not match any TS bid', async () => { - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ message: 'Prebid Request', adId: 'unknown-id' }), - ports: [], - }) - ); - - await new Promise((r) => setTimeout(r, 100)); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('ignores matching adId messages from outside configured slot iframes', async () => { - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - const foreignIframe = document.createElement('iframe'); - document.body.appendChild(foreignIframe); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const stopSpy = vi.fn(); - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort as unknown as MessagePort], - source: foreignIframe.contentWindow, - }) - ); - - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - expect(stopSpy).not.toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - foreignIframe.remove(); - }); - - it('ignores a request whose source slot does not own the resolved adId', async () => { - // Two configured slots; slot A's iframe requests slot B's hb_adid. The - // bridge must not return slot B's creative or fire slot B's beacons. - (window as TestWindow).tsjs!.bids!.homepage_footer = { - hb_adid: 'footer-uuid', - hb_bidder: 'kargo', - hb_pb: '2.00', - hb_cache_host: 'openads.example.com', - hb_cache_path: '/cache', - nurl: 'https://ssp.example/footer-win', - burl: 'https://ssp.example/footer-bill', - }; - (window as TestWindow).tsjs!.adSlots!.push({ - id: 'homepage_footer', - formats: [[300, 250]] as [number, number][], - gam_unit_path: '/a/b/footer', - div_id: 'div-footer', - targeting: {}, - }); - - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - await import('../../../src/integrations/gpt/index'); - fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); - - // Source iframe lives under slot A (div-header). - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - - window.dispatchEvent( - new MessageEvent('message', { - // adId belongs to slot B (homepage_footer), not slot A's iframe. - data: JSON.stringify({ message: 'Prebid Request', adId: 'footer-uuid' }), - ports: [fakePort as unknown as MessagePort], - source, - }) - ); - - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - expect(portMessages).toHaveLength(0); - expect(beaconSpy).not.toHaveBeenCalled(); - document.getElementById('div-footer')?.remove(); - }); - - it('ignores non-Prebid messages', async () => { - await import('../../../src/integrations/gpt/index'); - window.dispatchEvent( - new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }) - ); - await new Promise((r) => setTimeout(r, 50)); - expect(fetchStub).not.toHaveBeenCalled(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts index 79c86fef0..202402f8a 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts @@ -291,6 +291,15 @@ describe('generated terminal bootstrap fallback proposal', () => { auctionId: 'boot', results: [{ slot: 'known', outcome: 'no_bid' }], }, + slots: [ + { + slot: 'known', + gamUnitPath: '/123/known', + divId: 'known', + formats: [[300, 250]], + targeting: {}, + }, + ], bids: [], }, }, @@ -347,6 +356,7 @@ describe('generated terminal bootstrap fallback proposal', () => { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }, }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts deleted file mode 100644 index d50b697f4..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Mock } from 'vitest'; - -import type { LegacyTsjsApi } from '../../../src/core/types'; - -// We import installGptShim dynamically so each test can control whether the -// GPT enable flag is present before module evaluation. - -async function importGuardModule() { - return import('../../../src/integrations/gpt/script_guard'); -} - -type GptWindow = Window & { - googletag?: { - // The shim marks the queue it patched; `push` itself comes from `Array`, - // which GPT replaces with its own execute-immediately implementation. - cmd: Array<() => void> & { __tsPushed?: boolean }; - _loaded_?: boolean; - }; -}; - -describe('GPT shim – patchCommandQueue', () => { - let win: GptWindow; - let installGptShim: () => boolean; - - beforeEach(async () => { - // Reset any prior state - const guard = await importGuardModule(); - guard.resetGuardState(); - win = window as GptWindow; - delete win.googletag; - - // Dynamic import to get a fresh reference (the module self-init already - // ran at first import, but installGptShim is idempotent via the guard). - const mod = await import('../../../src/integrations/gpt/index'); - installGptShim = mod.installGptShim; - }); - - afterEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - delete (window as GptWindow).googletag; - }); - - it('preserves googletag.cmd array identity', () => { - const originalCmd: Array<() => void> = []; - win.googletag = { cmd: originalCmd }; - - installGptShim(); - - expect(win.googletag!.cmd).toBe(originalCmd); - }); - - it('preserves custom cmd.push when GPT is already loaded', () => { - // Simulate GPT's loaded state: cmd.push executes callbacks immediately. - const executed: string[] = []; - const cmd: Array<() => void> = []; - const gptCustomPush = (...fns: Array<() => void>): number => { - // GPT's custom push executes immediately and appends to the array. - for (const fn of fns) { - fn(); - cmd[cmd.length] = fn; - } - return cmd.length; - }; - cmd.push = gptCustomPush; - - win.googletag = { cmd, _loaded_: true }; - - installGptShim(); - - // Push a new callback after patching — it should still delegate to - // GPT's custom push (which executes immediately). - win.googletag!.cmd.push(() => { - executed.push('post-patch'); - }); - - expect(executed).toContain('post-patch'); - }); - - it('wraps callbacks pushed after patching with error handling', () => { - win.googletag = { cmd: [] }; - - installGptShim(); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Push a callback that throws - win.googletag!.cmd.push(() => { - throw new Error('test error'); - }); - - // The wrapped callback should be in the queue — execute it. - const wrappedFn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - expect(() => wrappedFn!()).not.toThrow(); - - errorSpy.mockRestore(); - }); - - it('re-wraps already-queued pending callbacks in place', () => { - const callOrder: string[] = []; - const pending = [() => callOrder.push('first'), () => callOrder.push('second')]; - - win.googletag = { cmd: pending }; - - installGptShim(); - - // The pending callbacks should have been wrapped in place. - // Execute them — they should not throw even if one of them did. - for (const fn of win.googletag!.cmd) { - fn(); - } - - expect(callOrder).toEqual(['first', 'second']); - }); - - it('handles pending callback that throws without breaking the queue', () => { - const callOrder: string[] = []; - const pending = [ - () => { - throw new Error('boom'); - }, - () => callOrder.push('after-error'), - ]; - - win.googletag = { cmd: pending }; - - installGptShim(); - - // Execute all wrapped callbacks — the error should be caught. - for (const fn of win.googletag!.cmd) { - expect(() => fn()).not.toThrow(); - } - - expect(callOrder).toEqual(['after-error']); - }); - - it('is idempotent — calling installGptShim twice does not double-wrap', () => { - const calls: number[] = []; - win.googletag = { cmd: [] }; - - installGptShim(); - const pushAfterFirst = win.googletag!.cmd.push; - - installGptShim(); - const pushAfterSecond = win.googletag!.cmd.push; - - // The push function should be the same reference (not re-wrapped). - expect(pushAfterSecond).toBe(pushAfterFirst); - - // Push a callback and verify it only executes once (not double-wrapped). - win.googletag!.cmd.push(() => calls.push(1)); - const fn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - fn!(); - - expect(calls).toEqual([1]); - }); - - it('creates googletag.cmd if it does not exist', () => { - // No googletag at all on window. - delete win.googletag; - - installGptShim(); - - expect(win.googletag).toBeDefined(); - expect(Array.isArray(win.googletag!.cmd)).toBe(true); - }); -}); - -describe('GPT – installSlimPrebidLoader', () => { - type SlimWindow = Window & { __tsjs_slim_prebid_url?: string }; - - afterEach(() => { - delete (window as SlimWindow).__tsjs_slim_prebid_url; - }); - - it('is a no-op when __tsjs_slim_prebid_url is not set', async () => { - const { installSlimPrebidLoader } = await import('../../../src/integrations/gpt/index'); - const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - installSlimPrebidLoader(); - expect(addEventListenerSpy).not.toHaveBeenCalledWith('load', expect.any(Function)); - addEventListenerSpy.mockRestore(); - }); - - it('appends a deferred script tag when __tsjs_slim_prebid_url is set and load fires', async () => { - (window as SlimWindow).__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid.js'; - const { installSlimPrebidLoader } = await import('../../../src/integrations/gpt/index'); - - installSlimPrebidLoader(); - - // Simulate the window load event. - window.dispatchEvent(new Event('load')); - - const scripts = Array.from(document.querySelectorAll('script[defer]')); - const injected = scripts.find( - (s) => (s as HTMLScriptElement).src === 'https://cdn.example.com/slim-prebid.js' - ); - expect(injected).toBeDefined(); - - // Clean up - injected?.parentNode?.removeChild(injected); - }); - - it('module init calls installSlimPrebidLoader — script injected when URL is preset', async () => { - vi.resetModules(); - (window as SlimWindow).__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid-init.js'; - - await import('../../../src/integrations/gpt/index'); - window.dispatchEvent(new Event('load')); - - const scripts = Array.from(document.querySelectorAll('script[defer]')); - const injected = scripts.find( - (s) => (s as HTMLScriptElement).src === 'https://cdn.example.com/slim-prebid-init.js' - ); - expect(injected).toBeDefined(); - - injected?.parentNode?.removeChild(injected); - }); -}); - -describe('GPT – installTsAdInit', () => { - // GPT slot mock: setTargeting/clearTargeting are chainable, so both return - // the slot itself. - interface MockGptSlot { - getSlotElementId: Mock<() => string>; - getTargeting: Mock<(key: string) => string[]>; - setTargeting: Mock<(key: string, value: string | string[]) => MockGptSlot>; - clearTargeting: Mock<(key?: string) => MockGptSlot>; - } - - // Minimal pubads surface adInit() drives. - interface MockPubAds { - getSlots: () => MockGptSlot[]; - enableSingleRequest: () => void; - addEventListener: (event: string, fn: (e: unknown) => void) => void; - refresh: (slots?: MockGptSlot[]) => void; - } - - interface MockGoogleTag { - cmd: Array<() => void>; - pubads: () => MockPubAds; - defineSlot: Mock; - destroySlots: Mock; - enableServices: Mock; - } - - // `tsjs` is declared globally as the full legacy API; `Omit` drops it from - // `Window` so the fixture below only has to satisfy the fields it sets. - type AdInitWindow = Omit & { - tsjs?: Partial; - googletag?: MockGoogleTag; - }; - - beforeEach(() => { - document.body.innerHTML = ''; - delete (window as AdInitWindow).tsjs; - delete (window as AdInitWindow).googletag; - }); - - afterEach(() => { - document.body.innerHTML = ''; - delete (window as AdInitWindow).tsjs; - delete (window as AdInitWindow).googletag; - }); - - it('clears stale TS-managed targeting before applying a new route to a reused GPT slot', async () => { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - const slotTargeting = new Map([ - ['hb_pb', ['1.20']], - ['hb_bidder', ['kargo']], - ['hb_adid', ['old-ad']], - ['hb_cache_host', ['cache.example.com']], - ['hb_cache_path', ['/cache']], - ['ts_initial', ['1']], - ['pos', ['old-pos']], - ]); - const gptSlot: MockGptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), - setTargeting: vi.fn((key: string, value: string | string[]) => { - slotTargeting.set(key, Array.isArray(value) ? value : [value]); - return gptSlot; - }), - clearTargeting: vi.fn((key?: string) => { - if (key) { - slotTargeting.delete(key); - } else { - slotTargeting.clear(); - } - return gptSlot; - }), - }; - const pubads = { - getSlots: vi.fn(() => [gptSlot]), - enableSingleRequest: vi.fn(), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const cmd: Array<() => void> = []; - cmd.push = (...callbacks: Array<() => void>) => { - callbacks.forEach((callback) => callback()); - return cmd.length; - }; - - document.body.innerHTML = '
'; - (window as AdInitWindow).googletag = { - cmd, - pubads: () => pubads, - defineSlot: vi.fn(), - destroySlots: vi.fn(), - enableServices: vi.fn(), - }; - (window as AdInitWindow).tsjs = { - prevSlotTargetingKeys: { - 'div-ad-homepage-header': ['pos'], - }, - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - bids: {}, - }; - - installTsAdInit(); - (window as AdInitWindow).tsjs!.adInit!(); - - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(gptSlot.clearTargeting).toHaveBeenCalledWith('pos'); - expect(slotTargeting.get('hb_pb')).toBeUndefined(); - expect(slotTargeting.get('hb_bidder')).toBeUndefined(); - expect(slotTargeting.get('hb_adid')).toBeUndefined(); - expect(slotTargeting.get('hb_cache_host')).toBeUndefined(); - expect(slotTargeting.get('hb_cache_path')).toBeUndefined(); - expect(slotTargeting.get('pos')).toBeUndefined(); - expect(slotTargeting.get('zone')).toEqual(['homepage']); - expect(slotTargeting.get('ts_initial')).toEqual(['1']); - }); -}); - -describe('GPT shim – runtime gating', () => { - type GatedWindow = Window & { - __tsjs_gpt_enabled?: boolean; - googletag?: { cmd: Array<() => void> }; - // Activation hook the module registers; the tests only assert its typeof. - __tsjs_installGptShim?: unknown; - }; - - let win: GatedWindow; - - beforeEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - win = window as GatedWindow; - delete win.googletag; - delete win.__tsjs_gpt_enabled; - }); - - afterEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - delete (window as GatedWindow).googletag; - delete (window as GatedWindow).__tsjs_gpt_enabled; - delete (window as GatedWindow).__tsjs_installGptShim; - }); - - it('installs the shim when activation function is called (simulates server inline script)', async () => { - const guard = await importGuardModule(); - const { installGptShim } = await import('../../../src/integrations/gpt/index'); - - // Simulate what the server-injected inline script does: - // set the flag then call the activation function. - win.__tsjs_gpt_enabled = true; - installGptShim(); - - expect(guard.isGuardInstalled()).toBe(true); - expect(win.googletag).toBeDefined(); - }); - - it('registers __tsjs_installGptShim on window after import', async () => { - vi.resetModules(); - await import('../../../src/integrations/gpt/index'); - - expect(typeof (window as GatedWindow).__tsjs_installGptShim).toBe('function'); - }); - - it('auto-installs the shim when the enable flag is set before import', async () => { - vi.resetModules(); - win.__tsjs_gpt_enabled = true; - - const guard = await importGuardModule(); - await import('../../../src/integrations/gpt/index'); - - expect(guard.isGuardInstalled()).toBe(true); - expect(win.googletag).toBeDefined(); - }); - - it('does not install the shim when only imported (no explicit activation)', async () => { - // Reset modules so the next dynamic import re-evaluates the module. - vi.resetModules(); - - const guard = await importGuardModule(); - // Import a fresh copy — the module should register the activation - // function on `window` but NOT call `installGptShim()` on its own. - await import('../../../src/integrations/gpt/index'); - - // Assert immediately — the guard must not be installed because the - // module only registers `__tsjs_installGptShim`, it does not auto-init. - expect(guard.isGuardInstalled()).toBe(false); - expect(win.googletag).toBeUndefined(); - }); -}); - -describe('GPT debug ADM iframe hardening', () => { - it('sandbox token list omits allow-same-origin', async () => { - const mod = await import('../../../src/integrations/gpt/index'); - - expect(mod.ADM_IFRAME_SANDBOX).toContain('allow-scripts'); - // allow-scripts + allow-same-origin on srcdoc content removes the - // sandbox's origin isolation — the pair must never be reintroduced. - expect(mod.ADM_IFRAME_SANDBOX).not.toContain('allow-same-origin'); - }); - - it('safeAdmIframeSrc accepts http(s), relative, and protocol-relative URLs', async () => { - const { safeAdmIframeSrc } = await import('../../../src/integrations/gpt/index'); - - expect(safeAdmIframeSrc('https://ads.example.com/creative')).toBe( - 'https://ads.example.com/creative' - ); - expect(safeAdmIframeSrc('http://ads.example.com/creative')).toBe( - 'http://ads.example.com/creative' - ); - expect(safeAdmIframeSrc('//ads.example.com/creative')).toBe('https://ads.example.com/creative'); - expect(safeAdmIframeSrc('/first-party/creative?sig=abc')).toBe('/first-party/creative?sig=abc'); - }); - - it('safeAdmIframeSrc rejects script-executing and opaque schemes', async () => { - const { safeAdmIframeSrc } = await import('../../../src/integrations/gpt/index'); - - expect(safeAdmIframeSrc('javascript:alert(1)')).toBeUndefined(); - expect(safeAdmIframeSrc('data:text/html,')).toBeUndefined(); - expect(safeAdmIframeSrc('blob:https://example.com/uuid')).toBeUndefined(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts index 57fed654c..8dbe0f0fe 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts @@ -534,6 +534,13 @@ describe('ordered GPT winner publication', () => { rendererReservationId: RESERVATION_ID, renderSource: source, }); + const placement = Object.freeze({ + slot: bid.slot, + gamUnitPath: '/123/gpt-slot', + divId: 'gpt-slot', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({ hb_bidder: 'publisher', pos: 'top' }), + }); const projection = Object.freeze({ version: 1, auction: Object.freeze({ @@ -547,6 +554,7 @@ describe('ordered GPT winner publication', () => { }), ]), }), + slots: Object.freeze([placement]), bids: Object.freeze([bid]), }); expect(harness.navigation.installAuctionProjection(projection)).toBe(true); @@ -567,6 +575,7 @@ describe('ordered GPT winner publication', () => { const facade: GoogletagFacade = Object.freeze({ bindingToken: () => Object.freeze({}), clearTargeting: (target: object, key?: string) => (target as typeof slot).clearTargeting(key), + transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), display: vi.fn(), getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), observeTargeting: () => { @@ -578,6 +587,7 @@ describe('ordered GPT winner publication', () => { Object.freeze({ apiReady: true, initialLoadDisabled: false, pubadsReady: true }), setTargeting: (target: object, key: string, value: string | readonly string[]) => (target as typeof slot).setTargeting(key, value), + slotElementId: () => undefined, slots: () => Object.freeze([slot]), subscribe: () => vi.fn(), transactionalReplace: () => Object.freeze({ status: 'destroyed' as const }), @@ -636,6 +646,7 @@ describe('ordered GPT winner publication', () => { navigation: harness.navigation, operation: 'refresh', owner: harness.primaryOwner, + placement, pucBridge, requestClass: 'primary', reservations, @@ -671,6 +682,7 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'target:pos', 'slot:validate', 'bridge', 'request', @@ -679,6 +691,7 @@ describe('ordered GPT winner publication', () => { new Map([ ['hb_adid', [RESERVATION_ID]], ['hb_bidder', ['trusted']], + ['pos', ['top']], ]) ); publication.bridgeArtifact()?.dispose(); @@ -745,6 +758,7 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'target:pos', 'slot:validate', ]); expect(publication.values.size).toBe(0); @@ -795,6 +809,7 @@ describe('ordered GPT winner publication', () => { 'slot:validate', 'target:hb_adid', 'target:hb_bidder', + 'target:pos', 'slot:validate', 'bridge', 'request', diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts deleted file mode 100644 index 07bfde6f5..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { LegacyTsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: LegacyTsjsApi; -}; - -const originalPushState = history.pushState.bind(history); -const originalReplaceState = history.replaceState.bind(history); - -/** - * Executable lifecycle coverage for `tsjs.scheduleInitialAdInit` — the - * deferred initial-adInit bootstrap the server's `` bids script hands - * off to. These tests run the real scheduler (and, where noted, the real - * `adInit()` and SPA auction hook) instead of string-matching the emitted - * script, so post-load ordering, two-frame deferral, exactly-once invocation, - * and stale-navigation cancellation are all exercised, not just spelled. - */ -describe('scheduleInitialAdInit', () => { - let rafQueue: FrameRequestCallback[]; - let readyState: DocumentReadyState; - let fetchStub: ReturnType; - let popstateHandlers: EventListenerOrEventListenerObject[] = []; - const realAddEventListener = window.addEventListener.bind(window); - - /** Run every queued animation-frame callback (one frame's worth). */ - function flushFrame(): void { - const queued = [...rafQueue]; - rafQueue.length = 0; - queued.forEach((cb) => cb(0)); - } - - /** Flush the microtask/timer queue so the SPA hook's awaits settle. */ - async function flushAsync(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - async function importGptModule() { - return import('../../../src/integrations/gpt/index'); - } - - beforeEach(() => { - vi.resetModules(); - delete (window as TestWindow).tsjs; - delete (window as TestWindow).googletag; - // Restore unwrapped history methods so each module import wraps exactly - // once — without this, wrappers from prior imports accumulate. - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - popstateHandlers = []; - vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { - if (type === 'popstate' && listener) popstateHandlers.push(listener); - return realAddEventListener(type, listener, options); - }); - // Manual animation-frame queue: the scheduler must be observed frame by - // frame, so frames only run when a test flushes them explicitly. - rafQueue = []; - ( - window as { requestAnimationFrame: typeof window.requestAnimationFrame } - ).requestAnimationFrame = ((cb: FrameRequestCallback) => { - rafQueue.push(cb); - return rafQueue.length; - }) as typeof window.requestAnimationFrame; - // Controllable document.readyState (jsdom reports 'complete' by default; - // the scheduler branches on it). - readyState = 'loading'; - Object.defineProperty(document, 'readyState', { - configurable: true, - get: () => readyState, - }); - }); - - afterEach(() => { - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - // Reset jsdom location back to root for the next test. - originalReplaceState({}, '', '/'); - document.body.innerHTML = ''; - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); - popstateHandlers = []; - // Remove the instance properties so the prototype getters are visible again. - delete (document as unknown as Record).readyState; - delete (document as unknown as Record).hidden; - delete (window as unknown as Record).requestAnimationFrame; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('applies the SSR payload and defers adInit until window load plus two animation frames', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ atf: { hb_pb: '1.00' } }); - // On the initial document (generation 0) the SSR bids are adopted - // immediately — the deferral applies to the GPT work, not the payload. - expect(ts.bids).toEqual({ atf: { hb_pb: '1.00' } }); - expect(adInit).not.toHaveBeenCalled(); - - // load alone must not run it — React commits after the load-time frame. - window.dispatchEvent(new Event('load')); - expect(adInit).not.toHaveBeenCalled(); - - // One frame is not enough: the double rAF exists so the call lands after - // React's post-hydration commit, not inside the load-event frame. - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('runs after two frames without a load event when the document is already complete', async () => { - readyState = 'complete'; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - // Still never synchronous — even past load, adInit waits two frames. - expect(adInit).not.toHaveBeenCalled(); - - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('invokes adInit exactly once even across duplicate load events and extra frames', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - window.dispatchEvent(new Event('load')); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - flushFrame(); - - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not rerun after a query-only page-bids refresh before load', async () => { - // The RC's SPA route identity includes pathname and query. A query change - // requests fresh page bids and runs adInit for that route, so the deferred - // initial callback must stand down instead of initializing the route twice. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - history.replaceState({}, '', '/?utm_source=newsletter'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(ts.navGeneration).toBe(1); - expect(adInit).not.toHaveBeenCalled(); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('cancels the initial run after an /a → /b → /a round trip before load', async () => { - // Both navigations commit and return to the original URL, so a URL - // comparison would see "unchanged" and run adInit a second time against - // the round-tripped route's live state. The navigation generation counts - // both commits and stands the initial callback down. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!(); - history.pushState({}, '', '/b'); - await flushAsync(); - history.pushState({}, '', '/'); - await flushAsync(); - expect(ts.navGeneration).toBe(2); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('drops the SSR payload when a navigation committed before scheduling', async () => { - // The SPA hook is installed by the synchronous head bundle, so a - // navigation can commit while the document is still streaming — before - // the script calls the scheduler. The SSR payload then belongs - // to a document the page has already left: it must not overwrite the - // live route's bids, and the initial adInit must never fire. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - ts.bids = { live_slot: { hb_pb: '2.50' } }; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); - expect(ts.bids).toEqual({ live_slot: { hb_pb: '2.50' } }); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('preserves a page-bids response applied before scheduling', async () => { - // Same race, with the SPA navigation's page-bids response fully applied - // (slots + bids + its own adInit) before the scheduler is called: the - // stale SSR payload must not corrupt the applied state, and the route's - // adInit count must stay at the SPA hook's single call. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '3.00' } }, - }), - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - - expect(ts.bids).toEqual({ s1: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels queued GPT work when a navigation commits before the command queue drains', async () => { - // adInit() only queues its slot work on googletag.cmd, which drains when - // GPT itself loads — possibly long after the generation check that - // guarded the adInit() call. A navigation in that gap must cancel the - // queued mutation, not let it run against the new route's DOM. - const commandQueue: Array<() => void> = []; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - const defineSlot = vi.fn(); - const destroySlots = vi.fn(); - (window as TestWindow).googletag = { - cmd: commandQueue, - defineSlot, - destroySlots, - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - document.body.innerHTML = '
'; - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00' } }; - - // GPT not loaded yet: the queued work sits in the command array. - ts.adInit!(); - expect(commandQueue.length).toBeGreaterThan(0); - - // A navigation commits before GPT drains the queue. - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.navGeneration).toBe(1); - - // GPT loads and drains the queue: the stale callback must stand down. - commandQueue.splice(0).forEach((fn) => fn()); - expect(defineSlot).not.toHaveBeenCalled(); - expect(destroySlots).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(mockPubads.enableSingleRequest).not.toHaveBeenCalled(); - }); - - it('rides animation frames in a hidden document, holding adInit until first view', async () => { - // Browsers do not service rAF while the document is hidden, so a - // background-tab load queues the frames but does not run them until the - // tab is first viewed. This is intended (see installScheduleInitialAdInit): - // the initial request spends its impression on a viewed tab. The scheduler - // must keep riding rAF — not switch to a timer — while hidden. - Object.defineProperty(document, 'hidden', { - configurable: true, - get: () => true, - }); - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ atf: { hb_pb: '1.00' } }); - window.dispatchEvent(new Event('load')); - - // Hidden tab: the frame chain is queued but unserviced — adInit waits. - expect(rafQueue.length).toBeGreaterThan(0); - expect(adInit).not.toHaveBeenCalled(); - - // First view: the browser services the pending frames. - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts deleted file mode 100644 index 139edcd34..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { LegacyTsjsApi } from '../../../src/core/types'; - -type TestWindow = Window & { - googletag?: unknown; - tsjs?: LegacyTsjsApi; -}; - -const originalPushState = history.pushState.bind(history); -const originalReplaceState = history.replaceState.bind(history); - -async function importGptModule() { - return import('../../../src/integrations/gpt/index'); -} - -/** Flush the microtask/timer queue so onNavigate's awaits settle. */ -async function flushAsync(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -/** Allow a MutationObserver-scheduled slot check to run. */ -async function flushAnimationFrame(): Promise { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - await Promise.resolve(); -} - -describe('installSpaAuctionHook', () => { - let fetchStub: ReturnType; - // popstate listeners registered by each module import. In production the hook - // installs once (guarded by `ts.spaHookInstalled`), but tests wipe - // `window.tsjs` and re-import per test, so without explicit removal the - // listeners accumulate on the shared window and all fire on every dispatch. - let popstateHandlers: EventListenerOrEventListenerObject[] = []; - const realAddEventListener = window.addEventListener.bind(window); - - beforeEach(() => { - vi.resetModules(); - delete (window as TestWindow).tsjs; - // Restore unwrapped history methods so each module import wraps exactly - // once — without this, wrappers from prior imports accumulate. - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - fetchStub = vi.fn(); - vi.stubGlobal('fetch', fetchStub); - popstateHandlers = []; - vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { - if (type === 'popstate' && listener) popstateHandlers.push(listener); - return realAddEventListener(type, listener, options); - }); - }); - - afterEach(() => { - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - // Reset jsdom location back to root for the next test. - originalReplaceState({}, '', '/'); - // Drop any ad containers inserted by a test so DOM state does not leak. - document.body.innerHTML = ''; - delete (window as TestWindow).googletag; - // Remove this test's popstate listener(s) so they do not fire in later tests. - popstateHandlers.forEach((handler) => window.removeEventListener('popstate', handler)); - popstateHandlers = []; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('increments navGeneration when a path-and-query navigation is accepted', async () => { - // The deferred initial-adInit bootstrap keys off this counter, so it must - // move in lockstep with the hook's route identity: bumped synchronously for - // each accepted pathname or query change, untouched by identical routes. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - expect(ts.navGeneration).toBe(0); - - history.pushState({}, '', '/next-page'); - expect(ts.navGeneration).toBe(1); - - history.replaceState({}, '', '/next-page?utm_source=x'); - expect(ts.navGeneration).toBe(2); - - history.pushState({}, '', '/next-page?utm_source=x'); - expect(ts.navGeneration).toBe(2); - await flushAsync(); - }); - - it('fetches page-bids on pushState and applies slots/bids via adInit', async () => { - // The route's ad container already exists, so bids apply immediately. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/next-page?edition=fictional#section'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Fnext-page%3Fedition%3Dfictional', - expect.objectContaining({ - credentials: 'include', - headers: { 'X-TSJS-Page-Bids': '1' }, - }) - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(ts.bids).toEqual({ s1: { hb_pb: '1.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('skips adInit on an empty page-bids response with no prior TS state', async () => { - // A gated page-bids response (auction kill switch or consent denial) returns - // no slots. With no prior TS state to sweep, the hook must not call adInit() - // so a consent-denied navigation cannot activate the publisher's GPT setup. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/gated-route'); - await flushAsync(); - - expect(ts.adSlots).toEqual([]); - expect(ts.bids).toEqual({}); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('runs adInit on an empty page-bids response when prior TS state exists', async () => { - // When TS touched slots on a previous navigation, an empty response still - // needs adInit() to sweep the stale TS targeting from those slots. - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.prevSlotTargetingKeys = { 'div-prev': ['hb_pb'] }; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/cleanup-route'); - await flushAsync(); - - expect(ts.adSlots).toEqual([]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('defers applying bids until the route ad container is inserted', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'late', div_id: 'div-late' }], - bids: { late: { hb_pb: '2.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // Navigate before the new route's container has rendered. - history.pushState({}, '', '/late-route'); - await flushAsync(); - expect(adInit).not.toHaveBeenCalled(); - expect(ts.adSlots).toBeUndefined(); - - // Container commits — the hook should now apply bids exactly once. - document.body.innerHTML = '
'; - await flushAnimationFrame(); - - expect(ts.adSlots).toEqual([{ id: 'late', div_id: 'div-late' }]); - expect(ts.bids).toEqual({ late: { hb_pb: '2.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('waits for every configured route ad container before applying bids', async () => { - document.body.innerHTML = '
'; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [ - { id: 'first', div_id: 'div-first' }, - { id: 'second', div_id: 'div-second' }, - ], - bids: { - first: { hb_pb: '1.00' }, - second: { hb_pb: '2.00' }, - }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/multi-slot-route'); - await flushAsync(); - - expect(adInit).not.toHaveBeenCalled(); - expect(ts.adSlots).toBeUndefined(); - - const second = document.createElement('div'); - second.id = 'div-second'; - document.body.appendChild(second); - await flushAnimationFrame(); - - expect(ts.adSlots).toEqual([ - { id: 'first', div_id: 'div-first' }, - { id: 'second', div_id: 'div-second' }, - ]); - expect(ts.bids).toEqual({ - first: { hb_pb: '1.00' }, - second: { hb_pb: '2.00' }, - }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not fetch when pushState targets the current path', async () => { - await importGptModule(); - - history.pushState({}, '', '/'); - await flushAsync(); - - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('fetches on replaceState navigation', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - history.replaceState({}, '', '/replaced'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Freplaced', - expect.objectContaining({ credentials: 'include' }) - ); - }); - - it('fetches on popstate navigation to a new path', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - // Browsers change the URL out-of-band on back/forward, then fire popstate. - // Use the unwrapped history method so the patched handler is not invoked. - originalReplaceState({}, '', '/popped'); - window.dispatchEvent(new PopStateEvent('popstate')); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledWith( - '/_ts/page-bids?path=%2Fpopped', - expect.objectContaining({ credentials: 'include' }) - ); - }); - - it('does not re-fetch on popstate to the same path', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - await importGptModule(); - - history.replaceState({}, '', '/replaced'); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - - // popstate on the same path (hash-only change or scroll-restoration - // back/forward) must not re-request impressions. - window.dispatchEvent(new PopStateEvent('popstate')); - await flushAsync(); - expect(fetchStub).toHaveBeenCalledTimes(1); - }); - - it('drops a stale response that resolves after a newer navigation started', async () => { - let resolveFirst: ((value: unknown) => void) | undefined; - fetchStub - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ slots: [{ id: 'newer', div_id: 'div-newer' }], bids: {} }), - }); - // Container for the newer route exists so its bids apply without waiting. - document.body.innerHTML = '
'; - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/first'); - history.pushState({}, '', '/second'); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); - expect(adInit).toHaveBeenCalledTimes(1); - - // First navigation's response arrives late — it must not overwrite the - // newer route's slots or trigger another adInit. - resolveFirst!({ - ok: true, - json: async () => ({ slots: [{ id: 'stale' }], bids: {} }), - }); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'newer', div_id: 'div-newer' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('stops orphan recovery before a fast route DOM swap can replay old bids', async () => { - document.body.innerHTML = '
'; - const definedDivs: string[] = []; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - refresh: vi.fn(), - addEventListener: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn((_path: string, _sizes: unknown, divId: string) => { - definedDivs.push(divId); - return { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(divId), - getTargeting: vi.fn().mockReturnValue([]), - }; - }), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - display: vi.fn(), - destroySlots: vi.fn(), - }; - // Keep page-bids slower than the orphan observer's 250 ms debounce. - fetchStub.mockReturnValue(new Promise(() => {})); - - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [ - { - id: 'ad-header-0', - gam_unit_path: '/123/header', - div_id: 'ad-header-0', - formats: [[728, 90]], - targeting: {}, - }, - ]; - ts.bids = { 'ad-header-0': { hb_adid: 'old-route-ad' } }; - ts.adInit!(); - expect(definedDivs).toEqual(['ad-header-0-_R_old_']); - - history.pushState({}, '', '/new-route'); - document.body.innerHTML = '
'; - await new Promise((resolve) => setTimeout(resolve, 350)); - - // The pending old-route watcher was disconnected synchronously when - // navigation began, so it never rebound or re-requested the old auction. - expect(definedDivs).toEqual(['ad-header-0-_R_old_']); - }); - - it('leaves slots and bids untouched on a non-OK response', async () => { - fetchStub.mockResolvedValue({ ok: false, status: 500 }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - ts.adSlots = [{ id: 'existing' } as never]; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/error-page'); - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'existing' }]); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('retries the same path after a failed page-bids fetch (currentPath rollback)', async () => { - // A failed load must roll `currentPath` back so re-navigating to the SAME - // path retries instead of being swallowed by the no-op guard at the top of - // onNavigate. Without the rollback, currentPath would already equal the - // failed path and the second navigation would return early. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // First navigation to the path fails; nothing is applied. - history.pushState({}, '', '/retry-page'); - await flushAsync(); - expect(ts.adSlots).toBeUndefined(); - - // Re-navigate to the same path — the retry must re-fetch and apply. - history.pushState({}, '', '/retry-page'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(2); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('does not strand a path that was aborted mid-flight then failed on the next nav', async () => { - // Rapid A→B where A is aborted mid-flight and B then fails must roll - // `currentPath` back to the last *applied* path (here the initial route), - // not to A. Rolling back to A — which never loaded — would leave it behind - // the no-op guard so a later real navigation to A never re-fetches. - document.body.innerHTML = '
'; - let resolveA: ((value: unknown) => void) | undefined; - fetchStub - // A: still in flight when B starts (aborted, never settles on its own). - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveA = resolve; - }) - ) - // B: fails. - .mockResolvedValueOnce({ ok: false, status: 500 }) - // A retried: succeeds. - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 'a', div_id: 'div-a' }], - bids: { a: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - // A starts (left in flight), then B aborts A and fails. - history.pushState({}, '', '/a'); - history.pushState({}, '', '/b'); - await flushAsync(); - expect(ts.adSlots).toBeUndefined(); - - // Navigate back to /a. With the rollback keyed to the last applied path - // (the initial route) instead of B's previous path (/a), this is NOT - // swallowed by the no-op guard and re-fetches. - history.pushState({}, '', '/a'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(3); - expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); - expect(adInit).toHaveBeenCalledTimes(1); - - // The original aborted A fetch resolving late must not clobber the retry. - resolveA?.({ ok: true, json: async () => ({ slots: [{ id: 'stale' }], bids: {} }) }); - await flushAsync(); - expect(ts.adSlots).toEqual([{ id: 'a', div_id: 'div-a' }]); - }); - - it('falls back to the deprecated alias when the canonical path is behind Basic Auth', async () => { - // An operator `[[handlers]]` regex broad enough to cover `/_ts` answers the - // canonical path with 401 that no anonymous browser fetch can satisfy. - // Without the fallback, every SPA navigation on that deployment loses ads. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/auth-gated'); - await flushAsync(); - - expect(fetchStub).toHaveBeenNthCalledWith( - 1, - '/_ts/page-bids?path=%2Fauth-gated', - expect.anything() - ); - // The fallback marks itself so the server can separate a current bundle - // that could not use the canonical path (a deployment to fix) from a - // pre-rename bundle (which ages out on its own). - expect(fetchStub).toHaveBeenNthCalledWith( - 2, - '/__ts/page-bids?path=%2Fauth-gated', - expect.objectContaining({ headers: { 'X-TSJS-Page-Bids': 'fallback' } }) - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('falls back to the deprecated alias when the canonical path returns a non-JSON body', async () => { - // A server rolled back to before the rename does not register the canonical - // path, so it falls through to the publisher-origin proxy and answers 200 - // HTML. That is the wrong endpoint, not a transient failure. - document.body.innerHTML = '
'; - fetchStub - .mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new SyntaxError('Unexpected token <'); - }, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - - history.pushState({}, '', '/rolled-back'); - await flushAsync(); - - expect(fetchStub).toHaveBeenNthCalledWith( - 2, - '/__ts/page-bids?path=%2Frolled-back', - expect.anything() - ); - expect(ts.adSlots).toEqual([{ id: 's1', div_id: 'div-s1' }]); - }); - - it('stays on the alias for the rest of the session once the fallback works', async () => { - // Re-probing the canonical path on every navigation would double the - // request count for the whole session on an affected deployment. - document.body.innerHTML = '
'; - fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 's1', div_id: 'div-s1' }], - bids: { s1: { hb_pb: '1.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - - history.pushState({}, '', '/first'); - await flushAsync(); - history.pushState({}, '', '/second'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(3); - expect(fetchStub).toHaveBeenNthCalledWith( - 3, - '/__ts/page-bids?path=%2Fsecond', - expect.anything() - ); - }); - - it('does not retry the alias when the endpoint denies the request', async () => { - // 403 is the cross-site gate, which applies to both registered paths — the - // alias would deny it identically, so retrying only burns a request. - fetchStub.mockResolvedValue({ ok: false, status: 403 }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/denied'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(ts.adSlots).toBeUndefined(); - expect(adInit).not.toHaveBeenCalled(); - }); - - it('is idempotent — repeated install calls do not double-fetch a navigation', async () => { - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ slots: [], bids: {} }), - }); - const { installSpaAuctionHook } = await importGptModule(); - // Module init already installed the hook; both calls must be no-ops. - installSpaAuctionHook(); - installSpaAuctionHook(); - - history.pushState({}, '', '/once'); - await flushAsync(); - - expect(fetchStub).toHaveBeenCalledTimes(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index f970ebe49..cb800e2d1 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -3878,96 +3878,3 @@ describe('prebid/client-side bidders', () => { errorSpy.mockRestore(); }); }); - -describe('prebid/self-init without the external bundle', () => { - afterEach(() => { - // Restore the module registry and the full mock global for later suites. - testWindow.pbjs = mockPbjs; - delete testWindow.googletag; - vi.resetModules(); - }); - - it('disables the integration and leaves pbjs and GPT untouched', async () => { - // Simulate a failed external bundle load: window.pbjs is still the - // head-injected stub with no Prebid.js API. The module captures the - // global at evaluation time, so reset the registry and re-import. - vi.resetModules(); - const barePbjs: { - que: Array<() => void>; - cmd: Array<() => void>; - requestBids?: unknown; - } = { que: [], cmd: [] }; - testWindow.pbjs = barePbjs; - const pubads = { refresh: vi.fn() }; - const cmdPush = vi.fn((callback: () => void) => callback()); - testWindow.googletag = { cmd: { push: cmdPush }, pubads: () => pubads }; - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - await import('../../../src/integrations/prebid/index'); - - // The bail-out is logged loudly. - const hasBailOutError = errorSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('has no Prebid.js API')) - ); - expect(hasBailOutError).toBe(true); - - // requestBids is left unwrapped and no adapter registration was attempted. - expect(barePbjs.requestBids).toBeUndefined(); - - // The refresh handler must not install: a wrapped googletag refresh - // would clear TS-applied targeting and then fail to run any auction. - expect(cmdPush).not.toHaveBeenCalled(); - expect( - (pubads as { refresh: unknown; __tsRefreshWrapped?: boolean }).__tsRefreshWrapped - ).toBeUndefined(); - - // The sentinel stays unset so a later successful install can still run. - expect(testWindow.__tsjsPrebidShimInstalled).toBeUndefined(); - - errorSpy.mockRestore(); - }); -}); - -describe('prebid self-init user ID module timing', () => { - const userSyncCallCount = () => - mockSetConfig.mock.calls.filter(([arg]) => arg && typeof arg === 'object' && 'userSync' in arg) - .length; - - const setReadyState = (value: DocumentReadyState) => { - Object.defineProperty(document, 'readyState', { value, configurable: true }); - }; - - beforeEach(() => { - vi.resetModules(); - mockSetConfig.mockClear(); - }); - - afterEach(() => { - setReadyState('complete'); - }); - - it('installs user ID modules immediately when the bundle loads after window load', async () => { - // The GPT slim loader appends this bundle from a window.load handler, so - // the document is already complete — a load listener would never fire. - setReadyState('complete'); - - await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBeGreaterThan(0); - }); - - it('defers user ID modules to window load when the document is still loading', async () => { - setReadyState('loading'); - - await import('../../../src/integrations/prebid/index'); - - expect(userSyncCallCount()).toBe(0); - - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - - // { once: true } — a second load event must not reinstall. - window.dispatchEvent(new Event('load')); - expect(userSyncCallCount()).toBe(1); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts index e7906f111..f23e86c25 100644 --- a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts @@ -1,21 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mirrorSourcepointConsent } from '../../../src/integrations/sourcepoint'; - -type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { - rewriteSdk?: boolean; - }; - __tsjs_installSourcepointGuard?: unknown; -}; +import { + disposeSourcepointConsentMirror, + initializeSourcepointConsentMirror, + mirrorSourcepointConsent, +} from '../../../src/integrations/sourcepoint'; +import { createSourcepointRuntime } from '../../../src/integrations/sourcepoint/module'; describe('Sourcepoint integration initialization', () => { - let win: SourcepointWindow; - beforeEach(async () => { - win = window as SourcepointWindow; - delete win.__tsjs_sourcepoint; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); guard.resetGuardState(); }); @@ -23,37 +16,22 @@ describe('Sourcepoint integration initialization', () => { afterEach(async () => { const guard = await import('../../../src/integrations/sourcepoint/script_guard'); guard.resetGuardState(); - delete win.__tsjs_sourcepoint; - delete win.__tsjs_installSourcepointGuard; }); it('installs the guard when rewriteSdk is enabled', async () => { - vi.resetModules(); - win.__tsjs_sourcepoint = { rewriteSdk: true }; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); + const release = createSourcepointRuntime().activate(Object.freeze({ rewriteSdk: true })); expect(guard.isGuardInstalled()).toBe(true); + release(); }); it('skips the guard when rewriteSdk is disabled', async () => { - vi.resetModules(); - win.__tsjs_sourcepoint = { rewriteSdk: false }; - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); + const release = createSourcepointRuntime().activate(Object.freeze({ rewriteSdk: false })); expect(guard.isGuardInstalled()).toBe(false); - }); - - it('defaults to installing the guard when rewriteSdk is missing for backward compatibility', async () => { - vi.resetModules(); - - const guard = await import('../../../src/integrations/sourcepoint/script_guard'); - await import('../../../src/integrations/sourcepoint/index'); - - expect(guard.isGuardInstalled()).toBe(true); + release(); }); }); @@ -83,11 +61,13 @@ describe('integrations/sourcepoint', () => { beforeEach(() => { // Clear cookies and localStorage before each test. + disposeSourcepointConsentMirror(); clearAllCookies(); localStorage.clear(); }); afterEach(() => { + disposeSourcepointConsentMirror(); vi.useRealTimers(); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); clearAllCookies(); @@ -277,7 +257,7 @@ describe('integrations/sourcepoint', () => { JSON.stringify(sourcepointPayload('initial-gpp', [7])) ); - mirrorSourcepointConsent(); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', JSON.stringify(sourcepointPayload('updated-gpp', [8])) @@ -294,7 +274,7 @@ describe('integrations/sourcepoint', () => { JSON.stringify(sourcepointPayload('initial-gpp', [7])) ); - mirrorSourcepointConsent(); + initializeSourcepointConsentMirror(); localStorage.removeItem('_sp_user_consent_12345'); window.dispatchEvent(new Event('focus')); @@ -309,7 +289,7 @@ describe('integrations/sourcepoint', () => { localStorage.clear(); clearAllCookies(); - await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', @@ -328,13 +308,13 @@ describe('integrations/sourcepoint', () => { clearAllCookies(); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); - const sourcepoint = await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', JSON.stringify(sourcepointPayload('manual-gpp', [7])) ); - expect(sourcepoint.mirrorSourcepointConsent()).toBe(true); + expect(mirrorSourcepointConsent()).toBe(true); localStorage.setItem( '_sp_user_consent_12345', @@ -354,7 +334,7 @@ describe('integrations/sourcepoint', () => { clearAllCookies(); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); - await import('../../../src/integrations/sourcepoint'); + initializeSourcepointConsentMirror(); localStorage.setItem( '_sp_user_consent_12345', diff --git a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts index be6c07539..76eae82d5 100644 --- a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -17,6 +17,7 @@ function boot(creative: unknown) { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'initial', results: [] }, + slots: [], bids: [], }, creative, diff --git a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts index 9af56233e..d03470155 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -15,6 +15,16 @@ function boot(results: readonly object[] = []) { auctionProjection: { version: 1, auction: { version: 1, auctionId: 'boot', results }, + slots: results.map((result) => { + const slot = (result as { readonly slot?: unknown }).slot; + return { + slot, + gamUnitPath: `/123/${String(slot)}`, + divId: String(slot), + formats: [[300, 250]], + targeting: {}, + }; + }), bids: [], }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, @@ -837,6 +847,7 @@ describe('Runtime bootstrap owner', () => { ).toEqual({ version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }); }); @@ -988,6 +999,7 @@ describe('Runtime bootstrap owner', () => { ).toEqual({ version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }); } @@ -1017,6 +1029,7 @@ describe('Runtime bootstrap owner', () => { ).toEqual({ version: 1, auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], bids: [], }); }); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 72e34774d..d01b2549e 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -467,158 +467,4 @@ describe('external bundle + served shim evaluated together', () => { adapter.dispose(); dom.window.close(); }, 60_000); - - it('populates the public API, installs the shim exactly once, and routes an /auction request', async () => { - const dom = new JSDOM('', { - url: 'https://pub.example.com/article', - runScripts: 'outside-only', - pretendToBeVisual: true, - }); - const pageWindow = dom.window; - - // Stub the network before any artifact runs: Prebid's ajax module - // captures window.fetch at evaluation time and builds Request objects. - // jsdom ships none of the fetch API, so lend it Node's — with relative - // URLs resolved against the page, as a browser Request would. - const fetchSpy = vi.fn( - async () => - new Response('{}', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ); - pageWindow.fetch = fetchSpy; - pageWindow.Request = class PageRequest extends Request { - constructor(resource, init) { - super( - typeof resource === 'string' - ? new URL(resource, 'https://pub.example.com').href - : resource, - init - ); - } - }; - pageWindow.Headers = Headers; - pageWindow.Response = Response; - pageWindow.AbortController = AbortController; - if (!('isSecureContext' in pageWindow)) { - pageWindow.isSecureContext = true; - } - - // Mirror the server's head-injected state, which always precedes the - // bundle script in document order. - pageWindow.eval('window.pbjs = { que: [], cmd: [] };'); - pageWindow.__tsjs_prebid = { clientSideBidders: [] }; - - pageWindow.eval(bundleCode); - - expect(typeof pageWindow.pbjs.requestBids).toBe('function'); - expect(typeof pageWindow.pbjs.registerBidAdapter).toBe('function'); - expect(pageWindow.__tsjs_prebid_bundle).toBeUndefined(); - expect(pageWindow.__tsjsPrebidShimInstalled).toBeUndefined(); - const artifactDescriptor = Object.getOwnPropertyDescriptor( - pageWindow.pbjs, - '__trustedServerArtifactV1' - ); - expect(artifactDescriptor).toMatchObject({ - enumerable: false, - writable: false, - configurable: false, - }); - expect(artifactDescriptor.value).toEqual( - expect.objectContaining({ - abi: 1, - artifactReleaseId: artifactManifest.artifactReleaseId, - prebidVersion: '10.26.0', - }) - ); - expect([...artifactDescriptor.value.bidderCodes]).toEqual(['adf', 'adform', 'adformOpenRTB']); - expect([...artifactDescriptor.value.bidderAliases]).toEqual([ - { code: 'adform', moduleStem: 'adf' }, - { code: 'adformOpenRTB', moduleStem: 'adf' }, - ]); - expect([...artifactDescriptor.value.userIdModules]).toEqual([ - { - moduleName: 'sharedIdSystem', - configNames: ['pubCommonId', 'sharedId'], - eidSources: ['pubcid.org'], - }, - ]); - expect(Object.isFrozen(artifactDescriptor.value)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.moduleStems)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.bidderCodes)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.bidderAliases)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.bidderAliases[0])).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0])).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].configNames)).toBe(true); - expect(Object.isFrozen(artifactDescriptor.value.userIdModules[0].eidSources)).toBe(true); - const adapter = createBrowserPrebidAdapter(pageWindow); - expect(adapter.bindingStatus()).toBe('present'); - adapter.dispose(); - - // Count trustedServer registrations across repeated shim evaluations. - const originalRegisterBidAdapter = pageWindow.pbjs.registerBidAdapter.bind(pageWindow.pbjs); - const registerSpy = vi.fn(originalRegisterBidAdapter); - pageWindow.pbjs.registerBidAdapter = registerSpy; - - pageWindow.eval(shimCode); - const wrappedRequestBids = pageWindow.pbjs.requestBids; - - // A second evaluation (double script inclusion, or a legacy bundle that - // still carries a baked-in shim running after this one) must be a no-op. - pageWindow.eval(shimCode); - - const trustedServerRegistrations = registerSpy.mock.calls.filter( - ([, bidderCode]) => bidderCode === 'trustedServer' - ); - expect(trustedServerRegistrations).toHaveLength(1); - expect(pageWindow.pbjs.requestBids).toBe(wrappedRequestBids); - expect(pageWindow.__tsjsPrebidShimInstalled).toBe(true); - - // Drive one real auction through the wrapped requestBids and assert the - // transformed request reaches /auction. - const slot = pageWindow.document.createElement('div'); - slot.id = 'ad-slot-1'; - pageWindow.document.body.appendChild(slot); - - pageWindow.pbjs.requestBids({ - adUnits: [ - { - code: 'ad-slot-1', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: { placementId: 1 } }], - }, - ], - timeout: 1000, - }); - - const requestUrl = (resource) => - typeof resource === 'string' ? resource : String(resource?.url ?? resource); - - await vi.waitFor( - () => { - expect( - fetchSpy.mock.calls.some(([resource]) => requestUrl(resource).includes('/auction')) - ).toBe(true); - }, - { timeout: 10_000 } - ); - - const [resource, init] = fetchSpy.mock.calls.find(([target]) => - requestUrl(target).includes('/auction') - ); - const body = init?.body ?? (typeof resource === 'object' ? await resource.text() : undefined); - const method = init?.method ?? resource?.method; - expect(method).toBe('POST'); - const payload = JSON.parse(body); - const adUnit = payload.adUnits[0]; - expect(adUnit.code).toBe('ad-slot-1'); - // The server-side bidder was folded into the trustedServer request - // instead of running client-side. - const trustedServerBid = adUnit.bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: { placementId: 1 } }); - - dom.window.close(); - }, 60_000); }); diff --git a/crates/trusted-server-js/lib/test/services/projections.test.ts b/crates/trusted-server-js/lib/test/services/projections.test.ts index 818583c0f..601e52ffc 100644 --- a/crates/trusted-server-js/lib/test/services/projections.test.ts +++ b/crates/trusted-server-js/lib/test/services/projections.test.ts @@ -7,6 +7,7 @@ import { createPageBidsController, prepareInitialAuctionProjection, type PreparedProjectionSlots, + type ProjectionSlotRegistration, type ProjectionSlotRegistry, } from '../../src/services/projections'; @@ -33,6 +34,13 @@ function projection(slots: readonly string[], auctionId = 'page-bids') { auctionId, results: slots.map((slot) => ({ slot, outcome: 'no_bid' as const })), }, + slots: slots.map((slot) => ({ + slot, + gamUnitPath: `/123/${slot}`, + divId: `div-${slot}`, + formats: [[300, 250]], + targeting: {}, + })), bids: [], }; } @@ -50,13 +58,13 @@ class SlotLedger implements ProjectionSlotRegistry { public prepareProjectionSlots( ownerGeneration: object, - slots: readonly string[], + slots: readonly ProjectionSlotRegistration[], maximumActiveSlots: number ): PreparedProjectionSlots | undefined { this.prepareCalls += 1; if ( this.slots.size + slots.length > maximumActiveSlots || - slots.some((slot) => this.slots.has(slot)) + slots.some((slot) => this.slots.has(slot.registeredSlotId)) ) { return undefined; } @@ -65,13 +73,13 @@ class SlotLedger implements ProjectionSlotRegistry { ownerGeneration, commit: () => { this.commitHook?.(); - for (const slot of slots) this.slots.add(slot); + for (const slot of slots) this.slots.add(slot.registeredSlotId); committed = true; return true; }, rollback: () => { if (!committed) return; - for (const slot of slots) this.slots.delete(slot); + for (const slot of slots) this.slots.delete(slot.registeredSlotId); committed = false; }, }); @@ -107,6 +115,36 @@ describe('initial auction projection', () => { }); describe('SPA page-bids projection controller', () => { + it('prepares exact placement aliases in the same transaction as projected slot ids', () => { + const runtime = runtimeSession(); + const initial = runtime.startInitialNavigation( + prepareInitialAuctionProjection(projection([], 'initial'), parseBrowserAuctionProjectionV1) + ); + if (!initial.ok) throw new Error(initial.reason); + const replacement = runtime.replaceNavigation(); + if (!replacement.ok) throw new Error(replacement.reason); + const prepareProjectionSlots = vi.fn(() => ({ + ownerGeneration: replacement.value.generation, + commit: () => true, + rollback: vi.fn(), + })); + + expect( + controller(replacement.value, { prepareProjectionSlots }).commit(projection(['server-slot'])) + ).toEqual({ status: 'committed' }); + expect(prepareProjectionSlots).toHaveBeenCalledExactlyOnceWith( + replacement.value.generation, + [ + { + registeredSlotId: 'server-slot', + domAliases: ['div-server-slot'], + }, + ], + 256 + ); + runtime.dispose(); + }); + it('atomically reserves slots and commits one immutable current-generation projection', () => { const runtime = runtimeSession(); const navigation = runtime.startInitialNavigation( diff --git a/crates/trusted-server-js/lib/test/services/slots.test.ts b/crates/trusted-server-js/lib/test/services/slots.test.ts index 54485e8b6..1f157eb6b 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -74,6 +74,7 @@ function createGptHarness( const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, clearTargeting: vi.fn(), + transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), display, getTargeting: vi.fn(() => []), observeTargeting: () => Object.assign(vi.fn(), { isCurrent: () => true }), @@ -87,6 +88,7 @@ function createGptHarness( pubadsReady: true, }), setTargeting: vi.fn(), + slotElementId: () => undefined, slots: () => Object.freeze([...slots]), subscribe: (eventType: string, listener: (event: unknown) => void) => { const registered = listeners.get(eventType) ?? new Set(); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 446c3146c..746860139 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -1,10 +1,22 @@ +import fs from 'node:fs'; import path from 'node:path'; import { configDefaults, defineConfig } from 'vitest/config'; +const integrationIds = fs + .readdirSync(path.resolve(import.meta.dirname, 'src/integrations'), { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.existsSync(path.resolve(import.meta.dirname, 'src/integrations', entry.name, 'index.ts')) + ) + .map((entry) => entry.name) + .sort(); + export default defineConfig({ define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify('a'.repeat(64)), + __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(integrationIds), }, resolve: { alias: { diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md index f83552b8a..a646f6743 100644 --- a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -2559,7 +2559,11 @@ implementation change. **Files:** - Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts` - Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/src/services/projections.ts` +- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` @@ -2577,11 +2581,20 @@ implementation change. - Modify: `crates/trusted-server-core/src/tsjs.rs` - Modify: `crates/trusted-server-core/src/auction/endpoints.rs` - Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` - Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` +- Modify: `crates/trusted-server-core/src/platform/mod.rs` +- Modify: `crates/trusted-server-core/src/platform/types.rs` - Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` - Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/main.rs` - Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/lib.rs` - Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/lib.rs` - Modify: `crates/trusted-server-core/src/html_processor.rs` - Modify: `crates/trusted-server-core/src/integrations/prebid.rs` - Modify: `crates/trusted-server-core/src/integrations/didomi.rs` @@ -2590,6 +2603,12 @@ implementation change. - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js` - Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/projections.test.ts` +- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/module.test.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` - [ ] **Step 1: Complete the pre-switch checklist with no production-wiring changes staged.** The atomic switch is allowed to flip wiring only after every behavior suite @@ -2648,6 +2667,19 @@ implementation change. - point `/auction`, initial HTML, and page-bids production emitters at the already-tested exact decision/projection serializers and boot-script fragments, including the preimplemented `tsjs:bids-script` mark; + - carry one exact ordered placement record for every initial/page-bids decision, + reject missing/extra/out-of-order placement coverage in the browser parser, and + keep only the direct `/auction` serializer's internal `slots:[]` exception; + - have the sole composition resolve each placement, adopt exactly one existing + publisher GPT slot or transactionally define/adopt one TS slot, merge static then + bid targeting with runtime-owned `hb_adid`, and publish both initial and committed + SPA winners through the same GPT/PUC lifecycle. Cover responsive-prefix ambiguity, + stale candidate destruction, publisher refresh versus TS display, attributable + empty-GAM direct fallback, and page-bids alias registration; + - preserve rc/july SPA route semantics in that composition: pathname-plus-query + identity across push/replace/pop, same-route suppression, stale-response + inertness, and rollback to the last committed path after a current failure so an + identical route can retry; - make the sole browser composition root construct the already-tested runtime, services, adapters, integration modules, fallback, and queue handoff, then have each thin integration `index.ts` delegate to that composition without retaining a @@ -2655,7 +2687,9 @@ implementation change. - switch generated release/manifest/config/bootstrap emission and the independently built pure Prebid 10.26.0 artifact to those already-tested entry points; and - register the already-tested versioned APS renderer and live unversioned runner - proxy through all four adapter dispatchers while preserving the negative routes. + proxy in the production registry and pre-router entry points of all four adapters, + preserving exact response headers and the negative routes before auth, generic + finalization, EC, integration filters, or publisher fallback can run. The switch is a hard cutover: add no selector, dual manifest, compatibility alias, protocol autodetection, or fallback to old behavior. The old implementation may diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md index bc1cfa365..a20e9c8c7 100644 --- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -920,6 +920,13 @@ interface AuctionDecisionSetV1 { interface BrowserAuctionProjectionV1 { version: 1 auction: AuctionDecisionSetV1 + slots: Array<{ + slot: string + gamUnitPath: string + divId: string + formats: Array + targeting: Record + }> bids: Array<{ candidateId: string slot: string @@ -936,11 +943,11 @@ interface BrowserAuctionProjectionV1 { `BrowserAuctionProjectionV1` is exact, deny-unknown, and bounded before any slot, reservation, targeting, or bid mutation. Its canonical UTF-8 JSON is at most -`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results` and -`bids` each contain at most 256 entries; and all objects are plain own-data objects +`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results`, `slots`, +and `bids` each contain at most 256 entries; and all objects are plain own-data objects with no accessors. Canonical serialization uses the interface field order shown, -request order for results, matching result order for bids, lexically sorted targeting -keys, and no insignificant whitespace. `auctionId` matches +request order for results, the same order for slots, matching result order for bids, +lexically sorted targeting keys, and no insignificant whitespace. `auctionId` matches `^[A-Za-z0-9._:-]{1,128}$`; candidate ids use the exact 12-character base64url form from §3.4 and are unique; result slots are unique, follow the §2.2 bound, and contain no NUL or ASCII control; every winner has @@ -950,6 +957,23 @@ exactly one bid with the same slot/candidate and non-winners have none; no NUL or ASCII control. CPM is a finite nonnegative number and currency is exactly `USD`. +For initial HTML and `/_ts/page-bids`, `slots.length` equals +`auction.results.length` exactly. Entry `slots[i].slot` equals +`auction.results[i].slot`; slot ids are unique; and the entire projection is rejected +if any placement is missing, duplicated, extra, or out of order. `gamUnitPath` and +`divId` are nonempty, contain no NUL or ASCII control, and are each at most 256 UTF-8 +bytes. `formats` contains 1–64 exact two-number tuples and every width and height is +an integer in 1–4096. Placement `targeting` uses the same exact key/value grammar and +32-entry cap as bid targeting and cannot contain `hb_adid`. + +The direct `/auction` response does not expose this browser projection shape. Its +internal use of the canonical decision/bid serializer supplies `slots:[]` because +there is no server-rendered GAM placement to bind; the wire response remains the +exact OpenRTB response plus decision extension. Rust canonicalization therefore +accepts either full ordered placement coverage or the direct-only empty placement +vector, while the browser boot/page-bids parser accepts only full ordered coverage. +No browser consumer interprets an empty placement vector for a nonempty decision set. + Each bid's `targeting` member is a plain own-data object with at most 32 entries. A key matches `^[A-Za-z0-9_]{1,20}$`, is unique and case-sensitive, and cannot be `hb_adid`, which the runtime alone synthesizes from the reservation. A value is @@ -965,7 +989,8 @@ the existing no-bid/failed results in request order. For `/auction`, the corresp TS winner bids are likewise absent from `seatbid`; no unmatched decision or bid is emitted. The reduced projection is guaranteed to fit from the 256-result/id bounds. Initial HTML, page-bids, and direct response production use this same all-winners -rule, never a completion-order or first-fit subset. Boot rejects any independently +rule, never a completion-order or first-fit subset. The aggregate measurement includes +the complete ordered placement vector for browser projections. Boot rejects any independently malformed or oversized value as `abi_mismatch`; page-bids and direct response admission reject it transactionally as `invalid_response` with no partial slot, reservation, targeting, or bid state. @@ -1084,6 +1109,10 @@ stores the document-generation input at value; an SPA page-bids response replaces only the new session's internal projection through the transaction in §2.5. It never mutates `tsjs.boot`. A winner decision must join exactly one projected bid and a no-bid/failed decision must join none. +Every decision also joins its exact ordered `slots` placement. Static placement +targeting is applied first, bid targeting overrides a duplicate static key, and the +runtime alone synthesizes `hb_adid` from `rendererReservationId`; neither server +targeting object may provide that key. Targeting applies one identity rule from §2.2 and never truncates a value to fit GAM. If the chosen value cannot satisfy the 40-character targeting limit, the bid is rejected before targeting with an explicit local reason. @@ -1124,6 +1153,19 @@ GAM `hb_adid`, publish other targeting, record GPT intent, and invoke a request-capable GPT operation—in that order. Any failure before request invocation tombstones the reservation, compare-restores targeting, and settles the attempt. +The composition resolves each projected `divId` to the exact element first, then to +one unambiguous responsive/hydrated prefix match; container-shell aliases are not +treated as creative roots and ambiguity fails `slot_unresolved`. If GPT already owns +exactly one live slot for the resolved element, the slot service adopts that publisher +object and publishes with `refresh`. Otherwise the sole GPT adapter performs a +transactional `defineSlot`/`addService`/adoption and publishes with `display`. +Staleness destroys the unadopted candidate, and no path may leave a second physical +slot. Initial boot and every successfully committed page-bids replacement use this +same publisher. `pushState`, `replaceState`, and `popstate` share pathname-plus-query +identity; identical routes are suppressed, a current failed/rejected response rolls +back to the last committed path so the same route can retry, and an older response +cannot roll back or publish over a newer navigation generation. + For the Trusted Server Prebid adapter, the supported artifact is the content-addressed external bundle built from exactly lockfile-resolved Prebid.js 10.26.0. The external artifact contains no Trusted Server auction, admission, render, or refresh behavior. @@ -1987,9 +2029,10 @@ timer, listener, port, or iframe. It never exposes a compatibility API. The safe fallback boot uses the embedded release and `manifest:{version:1,releaseId,integrations:[]}`, independently retains the server -auction projection only when that projection passes its exact shape/256-slot bounds, +auction projection only when that projection passes its exact shape, full ordered +placement coverage, 256-slot bounds, field grammars, render limits, and 8 MiB aggregate cap from §§3.1–3.2, and otherwise substitutes exactly -`{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}`. It +`{version:1,auction:{version:1,auctionId:'fallback',results:[]},slots:[],bids:[]}`. It retains a valid cache policy or omits it, and substitutes the creative/diagnostics disabled safe defaults from §§5.4/5.8 because no integration module commits. It never copies an accessor or unknown property. Fallback batch membership comes only from exact server slot ids in @@ -2012,23 +2055,23 @@ and late bundles; no valid call remains pending. There are no compatibility aliases: -| Baseline surface | Final surface | -| ---------------------------------------- | ------------------------------------------------------------------------------- | -| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` | -| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection`; internal navigation projection after SPA | -| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` | -| `globalThis.tscreative` | no callable equivalent; automatic creative module | -| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | -| void/callback `requestAds` | `tsjs.requestAds(options): Promise` | -| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` | -| placeholder `renderAllAdUnits` | `tsjs.requestAds()` | -| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config | -| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` | -| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` | -| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` | -| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp | -| integration install/patch sentinels | kernel integration registry/`WeakSet` | -| GPT slot expandos | `SlotRecord` | +| Baseline surface | Final surface | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` | +| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection` including exact ordered placements; internal navigation projection after SPA | +| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` | +| `globalThis.tscreative` | no callable equivalent; automatic creative module | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| void/callback `requestAds` | `tsjs.requestAds(options): Promise` | +| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` | +| placeholder `renderAllAdUnits` | `tsjs.requestAds()` | +| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config | +| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` | +| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` | +| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` | +| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp | +| integration install/patch sentinels | kernel integration registry/`WeakSet` | +| GPT slot expandos | `SlotRecord` | `window.tsjs.que` remains the pre-load command queue because it is the bootstrap transport, not a legacy behavior alias. @@ -2506,11 +2549,7 @@ subscription methods. The final schema is: ```ts type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' type RenderTraceServedFromV1 = - | 'inline' - | 'gam' - | 'debug-adm' - | 'pbs-cache' - | 'prebid' + 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid' interface RenderTraceRecord { readonly slotId: string From ad9d90efefd9ec367f13a48a33722afbab975257 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:29:57 -0700 Subject: [PATCH 410/494] Test hard-cutover browser lifecycle races --- .github/workflows/integration-tests.yml | 24 +- .../browser/helpers/gpt-stub.ts | 515 +++++-- .../browser/playwright.config.ts | 27 +- .../tests/nextjs/gpt-diagnostics.spec.ts | 52 +- .../browser/tests/nextjs/navigation.spec.ts | 49 + .../tests/shared/aps-puc-lifecycle.spec.ts | 331 ++++ .../browser/tests/shared/aps-renderer.spec.ts | 1353 +++-------------- .../tests/shared/creative-sandbox.spec.ts | 86 +- .../browser/tests/shared/tsjs-runtime.spec.ts | 244 +++ .../lib/src/adapters/googletag.ts | 42 +- .../trusted-server-js/lib/src/core/index.ts | 45 +- .../lib/test/adapters/googletag.test.ts | 20 + .../lib/test/core/index.test.ts | 1 + scripts/integration-tests-browser.sh | 39 +- 14 files changed, 1504 insertions(+), 1324 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 48c82f451..82d09010b 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -326,14 +326,14 @@ jobs: if-no-files-found: error retention-days: 30 - browser-tests-aps-v1: - name: browser integration tests (APS v1 feature artifact) + browser-tests-aps-tsjs-conformance: + name: browser integration tests (APS/TSJS conformance) runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Set up APS v1 browser test runtime + - name: Set up APS/TSJS browser test runtime id: shared-setup uses: ./.github/actions/setup-integration-test-env with: @@ -353,21 +353,25 @@ jobs: crates/trusted-server-integration-tests/browser/package-lock.json crates/trusted-server-js/lib/package-lock.json - - name: Run focused APS v1 Chromium test with explicit feature artifact + - name: Run focused APS/TSJS three-browser conformance matrix env: INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} TS_BROWSER_FRAMEWORKS: nextjs - TS_TEST_APS_V1: "1" + TS_BROWSER_PROJECTS: chromium,firefox,webkit run: >- ./scripts/integration-tests-browser.sh tests/shared/aps-renderer.spec.ts - --project=chromium - --grep="uses one port, reports ordered progress, and fails closed" - - - name: Upload APS v1 Playwright report + tests/shared/aps-puc-lifecycle.spec.ts + tests/shared/tsjs-runtime.spec.ts + tests/shared/creative-sandbox.spec.ts + tests/nextjs/gpt-diagnostics.spec.ts + tests/nextjs/navigation.spec.ts + --project=chromium --project=firefox --project=webkit + + - name: Upload APS/TSJS Playwright report uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report-aps-v1 + name: playwright-report-aps-tsjs-conformance path: crates/trusted-server-integration-tests/browser/playwright-report/ retention-days: 7 diff --git a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts index 6763b6b4b..40958eeec 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -2,115 +2,418 @@ import type { Page } from "@playwright/test"; /** Install a deterministic documented-event GPT stub before publisher scripts run. */ export async function installGptStub(page: Page): Promise { - await page.addInitScript(() => { - type StubSlot = { - getSlotElementId(): string; - getAdUnitPath(): string; - }; - type StubEvent = { slot: StubSlot } & Record; - type StubListener = (event: StubEvent) => void; + await page.addInitScript(() => { + type StubSlot = { + addService(service: object): StubSlot; + clearTargeting(key?: string): StubSlot; + getAdUnitPath(): string; + getSlotElementId(): string; + getTargeting(key: string): string[]; + setTargeting(key: string, value: string | string[]): StubSlot; + }; + type StubEvent = { slot: StubSlot } & Record; + type StubListener = (event: StubEvent) => void; - const listeners = new Map(); - const slots = new Map(); - const pubadsService = { - addEventListener(name: string, listener: StubListener) { - const current = listeners.get(name) ?? []; - current.push(listener); - listeners.set(name, current); - }, - refresh() {}, - }; - const commandQueue = { - push(callback: () => void) { - callback(); - return 1; - }, - }; - const googletag = { - cmd: commandQueue, - display() {}, - defineSlot() {}, - pubads: () => pubadsService, - }; - const references = { - commandPush: commandQueue.push, - display: googletag.display, - defineSlot: googletag.defineSlot, - refresh: pubadsService.refresh, - fetch: window.fetch, - xhrOpen: window.XMLHttpRequest.prototype.open, - pushState: window.history.pushState, - replaceState: window.history.replaceState, - }; + const listeners = new Map>(); + const slots = new Map(); + const physicalSlots = new Set(); + const displayCalls: StubSlot[] = []; + const refreshCalls: StubSlot[][] = []; + const universalCreativeLifecycle: string[] = []; + const emissions: Array>> = []; + const pageTargeting = new Map(); + let initialLoadDisabled = false; + let requestStartOnDisplay = false; + let nonemptyCompletionOnDisplay = false; + + const createSlot = (id: string, adUnitPath: string): StubSlot => { + const targeting = new Map(); + const slot: StubSlot = { + addService() { + return slot; + }, + clearTargeting(key?: string) { + if (key === undefined) targeting.clear(); + else targeting.delete(key); + return slot; + }, + getAdUnitPath: () => adUnitPath, + getSlotElementId: () => id, + getTargeting(key: string) { + return [...(targeting.get(key) ?? [])]; + }, + setTargeting(key: string, value: string | string[]) { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }, + }; + slots.set(id, slot); + return slot; + }; + + const slotForTarget = (target: unknown): StubSlot | undefined => { + if (typeof target === "string") return slots.get(target); + if (typeof target !== "object" || target === null) return undefined; + return [...physicalSlots].find((candidate) => candidate === target); + }; + + const emit = ( + name: string, + slot: StubSlot, + facts: Record = {}, + ): void => { + for (const listener of listeners.get(name) ?? []) { + listener({ slot, ...facts }); + } + }; - const browserWindow = window as unknown as { - googletag: typeof googletag; - __gptDiagnosticsStub: { - slot(id: string, adUnitPath?: string): StubSlot; - emit( - name: string, - slotId: string, - facts?: Record, - ): void; - listenerCounts(): Record; - captureReferences(): void; - referencesUnchanged(): boolean; + const pubadsService = { + addEventListener(name: string, listener: StubListener) { + const current = listeners.get(name) ?? new Set(); + current.add(listener); + listeners.set(name, current); + }, + removeEventListener(name: string, listener: StubListener) { + const current = listeners.get(name); + current?.delete(listener); + if (current?.size === 0) listeners.delete(name); + }, + disableInitialLoad() { + initialLoadDisabled = true; + }, + enableSingleRequest() {}, + getConfig() { + return { disableInitialLoad: initialLoadDisabled }; + }, + getSlots() { + return [...physicalSlots]; + }, + getTargeting(key: string) { + return [...(pageTargeting.get(key) ?? [])]; + }, + setTargeting(key: string, value: string | string[]) { + pageTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + return pubadsService; + }, + refresh(requestedSlots?: StubSlot[]) { + const requested = requestedSlots ?? [...physicalSlots]; + refreshCalls.push([...requested]); + }, + }; + const commandQueue = { + push(callback: () => void) { + callback(); + return 1; + }, + }; + const googletag = { + apiReady: true, + pubadsReady: true, + cmd: commandQueue, + destroySlots(requested?: StubSlot[]) { + const candidates = requested ?? [...physicalSlots]; + for (const slot of candidates) physicalSlots.delete(slot); + return true; + }, + display(target: string | StubSlot) { + const slot = slotForTarget(target); + if (slot) { + displayCalls.push(slot); + if (requestStartOnDisplay) { + emissions.push({ + name: "slotRequested", + listeners: listeners.get("slotRequested")?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit("slotRequested", slot); + if (nonemptyCompletionOnDisplay) { + emissions.push({ + name: "slotRenderEnded", + listeners: listeners.get("slotRenderEnded")?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit("slotRenderEnded", slot, { + isEmpty: false, + responseIdentifier: "fictional-response-1", + }); + } + } + } + }, + defineSlot(adUnitPath: string, _sizes: unknown, elementId: string) { + const existing = slots.get(elementId); + const slot = existing ?? createSlot(elementId, adUnitPath); + physicalSlots.add(slot); + return slot; + }, + getConfig() { + return { disableInitialLoad: initialLoadDisabled }; + }, + pubads: () => pubadsService, + setConfig(config: { disableInitialLoad?: unknown }) { + if (typeof config?.disableInitialLoad === "boolean") { + initialLoadDisabled = config.disableInitialLoad; + } + }, + }; + const references = { + commandPush: commandQueue.push, + display: googletag.display, + defineSlot: googletag.defineSlot, + refresh: pubadsService.refresh, + fetch: window.fetch, + xhrOpen: window.XMLHttpRequest.prototype.open, + pushState: window.history.pushState, + replaceState: window.history.replaceState, + }; + + function fictionalUniversalCreative( + adId: string, + lifecycleIndex: number, + ): void { + type OwnerResponse = { + adId: string; + renderer: string; + }; + type FictionalPucWindow = Window & { + __fictionalPucOutcome?: string; + render?: ( + data: OwnerResponse, + helper: object, + creativeWindow: Window, + ) => Promise; + }; + + const pucWindow = window as FictionalPucWindow; + pucWindow.__fictionalPucOutcome = "pending"; + const recordOutcome = (outcome: string): void => { + pucWindow.__fictionalPucOutcome = outcome; + ( + window.parent as Window & { + __recordFictionalPucOutcome(index: number, value: string): void; + } + ).__recordFictionalPucOutcome(lifecycleIndex, outcome); + }; + + const prebidMessenger = (): Promise => + new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = window.setTimeout(() => { + channel.port1.close(); + reject(new Error("fictional PUC response timeout")); + }, 5_000); + channel.port1.onmessage = (event) => { + window.clearTimeout(timeout); + channel.port1.close(); + try { + const response = JSON.parse(String(event.data)) as OwnerResponse; + resolve(response); + } catch (error) { + reject(error); + } + }; + channel.port1.start(); + window.parent.postMessage( + JSON.stringify({ + message: "Prebid Request", + adId, + adServerDomain: window.parent.location.host, + }), + "*", + [channel.port2], + ); + }); + + const runDynamicRenderer = async ( + response: OwnerResponse, + ): Promise => { + if (typeof response.renderer !== "string") { + throw new Error("fictional PUC response refused"); + } + window.eval(response.renderer); + if (typeof pucWindow.render !== "function") { + throw new Error("fictional PUC dynamic renderer unavailable"); + } + const helper = { + sendMessage( + message: string, + payload: Record, + callback: (event: MessageEvent) => void, + ) { + const channel = new MessageChannel(); + let active = true; + channel.port1.onmessage = (event) => { + if (active) callback(event); }; + channel.port1.start(); + window.parent.postMessage( + JSON.stringify({ + message, + adId: response.adId, + ...payload, + }), + "*", + [channel.port2], + ); + return () => { + active = false; + channel.port1.close(); + }; + }, }; - browserWindow.googletag = googletag; - browserWindow.__gptDiagnosticsStub = { - slot(id: string, adUnitPath = `/example/site/${id}`) { - let slot = slots.get(id); - if (!slot) { - slot = { - getSlotElementId: () => id, - getAdUnitPath: () => adUnitPath, - }; - slots.set(id, slot); - } - return slot; - }, - emit( - name: string, - slotId: string, - facts: Record = {}, - ) { - const slot = this.slot(slotId); - for (const listener of listeners.get(name) ?? []) { - listener({ slot, ...facts }); - } - }, - listenerCounts() { - return Object.fromEntries( - [...listeners.entries()].map(([name, registered]) => [ - name, - registered.length, - ]), - ); - }, - captureReferences() { - references.commandPush = commandQueue.push; - references.display = googletag.display; - references.defineSlot = googletag.defineSlot; - references.refresh = pubadsService.refresh; - references.fetch = window.fetch; - references.xhrOpen = window.XMLHttpRequest.prototype.open; - references.pushState = window.history.pushState; - references.replaceState = window.history.replaceState; - }, - referencesUnchanged() { - return ( - commandQueue.push === references.commandPush && - googletag.display === references.display && - googletag.defineSlot === references.defineSlot && - pubadsService.refresh === references.refresh && - window.fetch === references.fetch && - window.XMLHttpRequest.prototype.open === - references.xhrOpen && - window.history.pushState === references.pushState && - window.history.replaceState === references.replaceState - ); - }, + await pucWindow.render(response, helper, window); + }; + + void prebidMessenger() + .then(runDynamicRenderer) + .then( + () => { + recordOutcome("accepted"); + }, + (error: unknown) => { + const message = + error instanceof Error ? error.message : String(error); + recordOutcome(`failed:${message}`); + }, + ); + } + + const browserWindow = window as unknown as { + googletag: typeof googletag; + __recordFictionalPucOutcome(index: number, value: string): void; + __gptDiagnosticsStub: { + captureReferences(): void; + emitNonemptyCompletionOnDisplay(value?: boolean): void; + emitRequestStartOnDisplay(value?: boolean): void; + displayCount(): number; + emit( + name: string, + slotId: string, + facts?: Record, + ): void; + listenerCounts(): Record; + referencesUnchanged(): boolean; + refreshCount(): number; + renderUniversalCreative(slotId: string, adId: string): void; + slot(id: string, adUnitPath?: string): StubSlot; + targeting(slotId: string, key: string): readonly string[]; + universalCreativeSnapshot(): Readonly>; + }; + }; + browserWindow.googletag = googletag; + browserWindow.__recordFictionalPucOutcome = (index, value) => { + universalCreativeLifecycle[index] = value; + }; + browserWindow.__gptDiagnosticsStub = { + slot(id: string, adUnitPath = `/example/site/${id}`) { + return slots.get(id) ?? createSlot(id, adUnitPath); + }, + emit(name: string, slotId: string, facts: Record = {}) { + const slot = this.slot(slotId); + emissions.push({ + name, + listeners: listeners.get(name)?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit(name, slot, facts); + }, + listenerCounts() { + return Object.fromEntries( + [...listeners.entries()].map(([name, registered]) => [ + name, + registered.size, + ]), + ); + }, + displayCount() { + return displayCalls.length; + }, + emitRequestStartOnDisplay(value = true) { + requestStartOnDisplay = value; + }, + emitNonemptyCompletionOnDisplay(value = true) { + nonemptyCompletionOnDisplay = value; + }, + refreshCount() { + return refreshCalls.length; + }, + targeting(slotId: string, key: string) { + return slots.get(slotId)?.getTargeting(key) ?? []; + }, + renderUniversalCreative(slotId: string, adId: string) { + const root = document.getElementById(slotId); + if (!root) throw new Error(`missing fictional PUC slot: ${slotId}`); + const frame = document.createElement("iframe"); + const lifecycleIndex = universalCreativeLifecycle.length; + universalCreativeLifecycle.push("pending"); + frame.dataset.fictionalPuc = ""; + frame.srcdoc = ``; } -const FAKE_RUNNER = `(function(){ - var runnerRead = false; - var runnerWrite = false; - try { void top.document.body; runnerRead = true; } catch (_error) {} - try { top.document.body.dataset.apsCompromised = 'runner'; runnerWrite = true; } catch (_error) {} - parent.postMessage({ - message: 'fictional-runner-security', - runnerRead: runnerRead, - runnerWrite: runnerWrite, - accountMap: window._aps instanceof Map - }, '*'); - - addEventListener('message', function(event) { - if (event.data && event.data.message === 'fictional-creative-security') { - parent.postMessage(event.data, '*'); - } - }); - - window._aps.forEach(function(account) { - var events = account.queue.splice(0); - events.forEach(function(event) { - var response = JSON.parse(atob(event.detail.aaxResponse)); - var bid = response.seatbid[0].bid[0]; - if (bid.ext.tagtype === 'iframe') { - var frame = document.createElement('iframe'); - frame.setAttribute('sandbox', 'allow-scripts allow-same-origin'); - frame.src = bid.ext.creativeurl; - document.body.appendChild(frame); - } else { - var script = document.createElement('script'); - script.src = bid.ext.creativeurl; - document.head.appendChild(script); - } - }); - }); -})();`; - -const IFRAME_CREATIVE = ``, - }), - ); - await page.goto(runtimeUrl("/aps-v1-protocol-test")); - - const makeDescriptor = (bidId: string) => { - const value = descriptor("iframe"); - value.bidId = bidId; - const envelope = JSON.parse( - Buffer.from(value.aaxResponse, "base64").toString("utf8"), - ) as { seatbid: Array<{ bid: Array<{ id: string }> }> }; - envelope.seatbid[0].bid[0].id = bidId; - value.aaxResponse = Buffer.from( - JSON.stringify(envelope), - "utf8", - ).toString("base64"); - return value; - }; - const start = async ( - slotId: string, - bidId: string, - rendererOverrides: Record = {}, - ) => { - const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; - await page.evaluate( - ({ slotId, nonce, renderer }) => { - ( - window as unknown as { - startApsV1(options: Record): void; - } - ).startApsV1({ slotId, nonce, renderer }); - }, - { - slotId, - nonce, - renderer: { - ...makeDescriptor(bidId), - ...rendererOverrides, - }, - }, - ); - return nonce; - }; - const messages = (slotId: string) => - page.evaluate( - (id) => - ( - window as unknown as { - apsV1Records: Record< - string, - { messages: Array> } - >; - } - ).apsV1Records[id]?.messages ?? [], - slotId, - ); - - await start("duplicate-success", "duplicate-success-bid"); - await expect - .poll(async () => - (await messages("duplicate-success")).map( - (message) => message.message, - ), - ) - .toEqual([ - "TS APS Document Accepted", - "TS APS Runner Loaded", - "TS APS Render Completed", - ]); - await expect(page.locator("#duplicate-success .existing")).toHaveCount( - 0, - ); - expect( - (await messages("duplicate-success")).filter((message) => - String(message.message).includes("Render "), - ), - ).toHaveLength(1); - - await start("reject-case", "reject-case-bid"); - await expect - .poll(async () => await messages("reject-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Failed", - reason: "runner_failed", - }), - ); - await expect(page.locator("#reject-case .existing")).toHaveCount(1); - - await start("silent-case", "silent-case-bid"); - await expect - .poll(async () => - (await messages("silent-case")).map( - (message) => message.message, - ), - ) - .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); - await page.waitForTimeout(150); - expect(await messages("silent-case")).toHaveLength(2); - - await start("nested-case", "nested-case-bid"); - await expect - .poll(async () => await messages("nested-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Completed", - }), - ); - - const requestsBeforeInvalid = runnerRequests; - await start("invalid-case", "invalid-case-bid", { - unexpected: true, - }); - await expect - .poll(async () => await messages("invalid-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Failed", - reason: "descriptor_invalid", - }), - ); - expect(runnerRequests).toBe(requestsBeforeInvalid); - - await page.unroute(runtimeUrl("/integrations/aps/runner.js")); - await page.route(runtimeUrl("/integrations/aps/runner.js"), (route) => - route.abort(), - ); - await start("load-failure-case", "load-failure-case-bid"); - await expect - .poll(async () => await messages("load-failure-case")) - .toContainEqual( - expect.objectContaining({ - message: "TS APS Render Failed", - reason: "runner_no_load", - }), - ); + }), + ); + await page.goto(runtimeUrl("/aps-v1-protocol-test")); + + const makeDescriptor = (bidId: string) => { + const value = descriptor(); + value.bidId = bidId; + const envelope = JSON.parse( + Buffer.from(value.aaxResponse, "base64").toString("utf8"), + ) as { seatbid: Array<{ bid: Array<{ id: string }> }> }; + envelope.seatbid[0].bid[0].id = bidId; + value.aaxResponse = Buffer.from( + JSON.stringify(envelope), + "utf8", + ).toString("base64"); + return value; + }; + const start = async ( + slotId: string, + bidId: string, + rendererOverrides: Record = {}, + ) => { + const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; + await page.evaluate( + ({ slotId, nonce, renderer }) => { + ( + window as unknown as { + startApsV1(options: Record): void; + } + ).startApsV1({ slotId, nonce, renderer }); + }, + { + slotId, + nonce, + renderer: { + ...makeDescriptor(bidId), + ...rendererOverrides, + }, + }, + ); + return nonce; + }; + const messages = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV1Records: Record< + string, + { messages: Array> } + >; + } + ).apsV1Records[id]?.messages ?? [], + slotId, + ); + + await start("duplicate-success", "duplicate-success-bid"); + await expect + .poll(async () => + (await messages("duplicate-success")).map((message) => message.message), + ) + .toEqual([ + "TS APS Document Accepted", + "TS APS Runner Loaded", + "TS APS Render Completed", + ]); + await expect(page.locator("#duplicate-success .existing")).toHaveCount(0); + expect( + (await messages("duplicate-success")).filter((message) => + String(message.message).includes("Render "), + ), + ).toHaveLength(1); + + await start("reject-case", "reject-case-bid"); + await expect + .poll(async () => await messages("reject-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_failed", + }), + ); + await expect(page.locator("#reject-case .existing")).toHaveCount(1); + + await start("silent-case", "silent-case-bid"); + await expect + .poll(async () => + (await messages("silent-case")).map((message) => message.message), + ) + .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); + await page.waitForTimeout(150); + expect(await messages("silent-case")).toHaveLength(2); + + await start("nested-case", "nested-case-bid"); + await expect + .poll(async () => await messages("nested-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Completed", + }), + ); + + const requestsBeforeInvalid = runnerRequests; + await start("invalid-case", "invalid-case-bid", { + unexpected: true, }); + await expect + .poll(async () => await messages("invalid-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "descriptor_invalid", + }), + ); + expect(runnerRequests).toBe(requestsBeforeInvalid); + + await page.unroute(runtimeUrl("/integrations/aps/runner.js")); + await page.route(runtimeUrl("/integrations/aps/runner.js"), (route) => + route.abort(), + ); + await start("load-failure-case", "load-failure-case-bid"); + await expect + .poll(async () => await messages("load-failure-case")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_no_load", + }), + ); + }); }); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts index 517aeb403..7b3ef9f71 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts @@ -19,13 +19,46 @@ const CREATIVE_SANDBOX_TOKENS = [ // its own origin ahead of any creative markup, then the runtime, then the // creative. The anchor carries a root-relative signed click exactly as the // server-side rewriter emits it. -function creativeDocument(origin: string, bundleUrl: string): string { +function creativeDocument( + origin: string, + bundleUrl: string, + releaseId: string, +): string { const signedClick = "/first-party/click?tsurl=https%3A%2F%2Fadvertiser.example%2Flanding&foo=1&tstoken=browser-test-token"; + const boot = JSON.stringify({ + abi: 1, + releaseId, + manifest: { + version: 1, + releaseId, + integrations: [{ id: "creative", required: true }], + }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: "creative-sandbox", results: [] }, + slots: [], + bids: [], + }, + creative: { + version: 1, + enabled: true, + clickGuard: true, + renderGuard: false, + }, + diagnostics: { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }, + }); return ` - + @@ -43,14 +76,19 @@ test.describe("Sandboxed creative iframe", () => { // Prefer whichever hashed bundle URL the server injected into the page so // this test never has to know the current content hash; fall back to the // stable unified path if the fixture page carries no injected script. - const injectedBundle = await page.evaluate(() => { + const runtime = await page.evaluate(() => { const script = Array.from(document.querySelectorAll("script[src]")).find( - (element) => (element as HTMLScriptElement).src.includes("/static/tsjs="), + (element) => + (element as HTMLScriptElement).src.includes("/static/tsjs="), ); - return script ? (script as HTMLScriptElement).src : null; + return { + bundleUrl: script ? (script as HTMLScriptElement).src : null, + releaseId: (window as any).tsjs?.releaseId as string | undefined, + }; }); const bundleUrl = - injectedBundle ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js"); + runtime.bundleUrl ?? runtimeUrl("/static/tsjs=tsjs-unified.min.js"); + expect(runtime.releaseId).toMatch(/^[a-f0-9]{64}$/); const rebuildRequest = page.waitForRequest( (request) => request.url().includes("/first-party/proxy-rebuild"), @@ -68,13 +106,47 @@ test.describe("Sandboxed creative iframe", () => { }, { sandbox: CREATIVE_SANDBOX_TOKENS, - html: creativeDocument(new URL(runtimeUrl("/")).origin, bundleUrl), + html: creativeDocument( + new URL(runtimeUrl("/")).origin, + bundleUrl, + runtime.releaseId!, + ), }, ); const frame = page.frameLocator("iframe"); const link = frame.locator("#creative-link"); await link.waitFor({ state: "attached", timeout: 10_000 }); + await expect + .poll(() => + frame.locator("html").evaluate(() => { + const api = (window as any).tsjs; + return { + state: api?._internal?.state, + names: Object.getOwnPropertyNames(api ?? {}).sort(), + legacyCreativeGlobal: Object.prototype.hasOwnProperty.call( + window, + "tscreative", + ), + }; + }), + ) + .toEqual({ + state: "kernel", + names: [ + "_internal", + "_registerIntegration", + "addAdUnits", + "boot", + "diagnostics", + "log", + "que", + "releaseId", + "requestAds", + "version", + ], + legacyCreativeGlobal: false, + }); // The creative mutates its own click target, the shape the click guard // exists to repair. diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts new file mode 100644 index 000000000..90ab19809 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts @@ -0,0 +1,244 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, test, type Page } from "@playwright/test"; + +const TSJS_CRATE = resolve(__dirname, "../../../../trusted-server-js"); +const CORE_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-core.js"); +const GPT_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-gpt.js"); +const RELEASE = JSON.parse( + readFileSync(resolve(TSJS_CRATE, "dist/tsjs-release-v1.json"), "utf8"), +) as { releaseId: string }; + +function boot(releaseId: string) { + return { + abi: 1, + releaseId, + manifest: { version: 1, releaseId, integrations: [] }, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: "browser-initial", results: [] }, + slots: [], + bids: [], + }, + creative: { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }, + diagnostics: { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }, + }; +} + +async function waitForRuntime(page: Page, state: "kernel" | "fallback") { + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ), + ) + .toBe(state); +} + +async function openRuntimePage(page: Page) { + await page.route("https://runtime.test/fixture", (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: '
', + }), + ); + await page.goto("https://runtime.test/fixture"); +} + +test.describe("TSJS hard-cutover runtime", () => { + test("publishes only the kernel API and drains a hostile preload queue once", async ({ + page, + }) => { + await openRuntimePage(page); + await page.evaluate((initialBoot) => { + const browserWindow = window as unknown as { + queueOrder: string[]; + tsjs: Record & { que: Array<() => void> }; + }; + browserWindow.queueOrder = []; + const que = [ + () => { + browserWindow.queueOrder.push("first"); + browserWindow.tsjs.que.push(() => + browserWindow.queueOrder.push("nested"), + ); + }, + () => { + browserWindow.queueOrder.push("throw"); + throw new Error("publisher callback failure"); + }, + () => browserWindow.queueOrder.push("last"), + ]; + browserWindow.tsjs = { + boot: initialBoot, + que, + _integrationConfig: {}, + bids: { legacy: true }, + renderAdUnit() {}, + renderAllAdUnits() {}, + setConfig() {}, + getConfig() {}, + }; + }, boot(RELEASE.releaseId)); + + await page.addScriptTag({ path: CORE_BUNDLE }); + await waitForRuntime(page, "kernel"); + + const state = await page.evaluate(() => { + const api = (window as unknown as { tsjs: Record }).tsjs; + return { + names: Object.getOwnPropertyNames(api).sort(), + queueOrder: ( + window as unknown as { queueOrder: string[] } + ).queueOrder.slice(), + queueFrozen: Object.isFrozen(api.que), + bootFrozen: Object.isFrozen(api.boot), + releaseId: api.releaseId, + legacy: [ + "bids", + "renderAdUnit", + "renderAllAdUnits", + "setConfig", + "getConfig", + "adInit", + "renders", + "gptDiagnostics", + ].filter((name) => Object.prototype.hasOwnProperty.call(api, name)), + }; + }); + + expect(state.names).toEqual([ + "_internal", + "_registerIntegration", + "addAdUnits", + "boot", + "diagnostics", + "log", + "que", + "releaseId", + "requestAds", + "version", + ]); + expect(state.queueOrder).toEqual( + expect.arrayContaining(["first", "nested", "throw", "last"]), + ); + expect(new Set(state.queueOrder).size).toBe(4); + expect(state.queueFrozen).toBe(true); + expect(state.bootFrozen).toBe(true); + expect(state.releaseId).toBe(RELEASE.releaseId); + expect(state.legacy).toEqual([]); + }); + + test("terminal fallback cannot be revived by a late integration bundle", async ({ + page, + }) => { + await openRuntimePage(page); + await page.evaluate((initialBoot) => { + const browserWindow = window as unknown as { + tsjs: Record; + fallbackEffects: { messageListeners: number; timeouts: number }; + }; + browserWindow.fallbackEffects = { messageListeners: 0, timeouts: 0 }; + const nativeAddEventListener = window.addEventListener.bind(window); + window.addEventListener = (( + type: string, + listener: EventListenerOrEventListenerObject, + ) => { + if (type === "message") + browserWindow.fallbackEffects.messageListeners += 1; + nativeAddEventListener(type, listener); + }) as typeof window.addEventListener; + const nativeSetTimeout = window.setTimeout.bind(window); + window.setTimeout = ((handler: TimerHandler, timeout?: number) => { + browserWindow.fallbackEffects.timeouts += 1; + return nativeSetTimeout(handler, timeout); + }) as typeof window.setTimeout; + browserWindow.tsjs = { + boot: initialBoot, + que: [], + _integrationConfig: Object.create({ hostile: true }), + }; + }, boot(RELEASE.releaseId)); + + await page.addScriptTag({ path: CORE_BUNDLE }); + await waitForRuntime(page, "fallback"); + const before = await page.evaluate(() => ({ + effects: { + ...( + window as unknown as { + fallbackEffects: { messageListeners: number; timeouts: number }; + } + ).fallbackEffects, + }, + names: Object.getOwnPropertyNames( + (window as unknown as { tsjs: object }).tsjs, + ).sort(), + })); + + await page.addScriptTag({ path: GPT_BUNDLE }); + await page.evaluate(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { message: "Prebid Request" } }), + ); + }); + await page.waitForTimeout(25); + + const after = await page.evaluate(() => { + const browserWindow = window as unknown as { + tsjs: { + _internal: { state: string; reason: string }; + _registerIntegration(value: unknown): boolean; + }; + fallbackEffects: { messageListeners: number; timeouts: number }; + googletag?: unknown; + }; + return { + internal: browserWindow.tsjs._internal, + registrationAccepted: browserWindow.tsjs._registerIntegration({}), + effects: { ...browserWindow.fallbackEffects }, + frames: document.querySelectorAll("iframe").length, + scripts: document.querySelectorAll("script").length, + hasGoogletag: Object.prototype.hasOwnProperty.call(window, "googletag"), + }; + }); + + expect(before.names).toEqual([ + "_internal", + "_registerIntegration", + "addAdUnits", + "boot", + "log", + "que", + "releaseId", + "requestAds", + "version", + ]); + expect(after.internal).toMatchObject({ + state: "fallback", + reason: "abi_mismatch", + }); + expect(after.registrationAccepted).toBe(false); + expect(after.effects.messageListeners).toBe( + before.effects.messageListeners, + ); + expect(after.effects.timeouts).toBe(before.effects.timeouts); + expect(after.frames).toBe(0); + expect(after.scripts).toBe(2); + expect(after.hasGoogletag).toBe(false); + }); +}); diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index af6b1fe44..bd6882b92 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -980,6 +980,10 @@ export function createBrowserGoogletagAdapter( let armedBindings = new WeakSet(); const targetingObservations = new WeakMap(); const facadeCalls = new WeakMap<(...arguments_: unknown[]) => unknown, number>(); + const adapterMethodOrigins = new WeakMap< + (...arguments_: unknown[]) => unknown, + (...arguments_: unknown[]) => unknown + >(); const bindingTokens = new WeakMap(); const diagnosticsSlots = new WeakMap(); const initialLoadReleases = new Map void>(); @@ -1576,13 +1580,33 @@ export function createBrowserGoogletagAdapter( }; const sameBinding = (expected: PresentGoogletag): boolean => { + const canonicalAdapterMethod = ( + candidate: (...arguments_: unknown[]) => unknown + ): ((...arguments_: unknown[]) => unknown) | undefined => { + let current = candidate; + for (let depth = 0; depth < 16; depth += 1) { + const origin = weakMapValue(adapterMethodOrigins, current); + if (!origin) return current; + if (origin === current) return undefined; + current = origin; + } + return undefined; + }; + const sameAdapterMethod = ( + left: (...arguments_: unknown[]) => unknown, + right: (...arguments_: unknown[]) => unknown + ): boolean => { + if (left === right) return true; + const canonicalLeft = canonicalAdapterMethod(left); + return canonicalLeft !== undefined && canonicalLeft === canonicalAdapterMethod(right); + }; const matchesCapturedBinding = (): boolean => { const inspected = inspectBinding(expected.binding); return ( inspected.status === 'present' && inspected.value.commandQueue.binding === expected.commandQueue.binding && inspected.value.commandQueue.push === expected.commandQueue.push && - inspected.value.display === expected.display && + sameAdapterMethod(inspected.value.display, expected.display) && inspected.value.pubads === expected.pubads ); }; @@ -2300,9 +2324,19 @@ export function createBrowserGoogletagAdapter( } return mediate(callable, this, arguments_); }; - const restore = replaceMethod(external, key, wrapper, stillCurrent); - if (!restore) throw new GoogletagAdapterError('external_artifact_incompatible'); - restorers[restorers.length] = restore; + setWeakMapValue(adapterMethodOrigins, wrapper, callable); + const restoreMethod = replaceMethod(external, key, wrapper, stillCurrent); + if (!restoreMethod) { + deleteWeakMapValue(adapterMethodOrigins, wrapper); + throw new GoogletagAdapterError('external_artifact_incompatible'); + } + restorers[restorers.length] = (): void => { + try { + restoreMethod(); + } finally { + deleteWeakMapValue(adapterMethodOrigins, wrapper); + } + }; }; try { install(currentBindingObject, 'defineSlot', (original, receiver, arguments_) => { diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 93ef0a0b6..ea0935b88 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -38,10 +38,7 @@ export type BrowserRuntimeCompositionFactory = ( function bootstrapTarget(): BootstrapTarget | undefined { try { const current = (window as unknown as { tsjs?: unknown }).tsjs; - if ( - (typeof current === 'object' || typeof current === 'function') && - current !== null - ) { + if ((typeof current === 'object' || typeof current === 'function') && current !== null) { return current as BootstrapTarget; } const target: BootstrapTarget = {}; @@ -58,11 +55,7 @@ function snapshotConfigValue( state: { nodes: number }, depth = 0 ): unknown | typeof INVALID_CONFIG { - if ( - candidate === null || - typeof candidate === 'string' || - typeof candidate === 'boolean' - ) { + if (candidate === null || typeof candidate === 'string' || typeof candidate === 'boolean') { return candidate; } if (typeof candidate === 'number') { @@ -119,14 +112,10 @@ function snapshotConfigValue( } } -function consumeIntegrationConfig( - target: BootstrapTarget +function snapshotIntegrationConfig( + candidate: unknown ): Readonly> | undefined { try { - const descriptor = Object.getOwnPropertyDescriptor(target, '_integrationConfig'); - if (!descriptor) return Object.freeze({}); - if (!('value' in descriptor) || !descriptor.configurable) return undefined; - const candidate = descriptor.value; if ( typeof candidate !== 'object' || candidate === null || @@ -152,13 +141,33 @@ function consumeIntegrationConfig( if (value === INVALID_CONFIG) return undefined; configs[name] = value; } - if (!Reflect.deleteProperty(target, '_integrationConfig')) return undefined; return Object.freeze(configs); } catch { return undefined; } } +function consumeIntegrationConfig( + target: BootstrapTarget +): Readonly> | undefined { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(target, '_integrationConfig'); + } catch { + return undefined; + } + if (!descriptor) return Object.freeze({}); + if (!('value' in descriptor) || !descriptor.configurable) return undefined; + + const configs = snapshotIntegrationConfig(descriptor.value); + try { + if (!Reflect.deleteProperty(target, '_integrationConfig')) return undefined; + } catch { + return undefined; + } + return configs; +} + function bootManifest(target: BootstrapTarget): unknown { try { const boot = Object.getOwnPropertyDescriptor(target, 'boot'); @@ -173,9 +182,7 @@ function bootManifest(target: BootstrapTarget): unknown { } /** Claim the browser namespace and start the injected sole composition root. */ -export function startProductionRuntime( - createComposition: BrowserRuntimeCompositionFactory -): void { +export function startProductionRuntime(createComposition: BrowserRuntimeCompositionFactory): void { const target = bootstrapTarget(); if (!target) return; const configs = consumeIntegrationConfig(target); diff --git a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts index 6e073ee16..9609fbc3e 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -2132,6 +2132,26 @@ describe('browser googletag adapter readiness', () => { expect(ready.pubads.refresh).toBe(nativeRefresh); }); + it('keeps an existing event subscription live while installing publisher-call wrappers', async () => { + const ready = createReadyGoogletag(); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const listener = vi.fn(); + let unsubscribe: (() => void) | undefined; + const subscription = adapter.run((gpt) => { + unsubscribe = gpt.subscribe('slotRequested', listener); + }); + await expect(subscription.result).resolves.toBeUndefined(); + const installed = [...(ready.listeners.get('slotRequested') ?? [])][0]; + expect(installed).toBeTypeOf('function'); + + const releasePublisherObserver = adapter.observePublisherCalls(Object.freeze({})); + installed?.({ slot: Object.freeze({ id: 'slot-a' }) }); + + expect(listener).toHaveBeenCalledOnce(); + unsubscribe?.(); + releasePublisherObserver(); + }); + it('installs the publisher observer when an accepted command-queue stub becomes ready', () => { const commands: Array<() => void> = []; const pending = { diff --git a/crates/trusted-server-js/lib/test/core/index.test.ts b/crates/trusted-server-js/lib/test/core/index.test.ts index b9cdf8324..bc54d39bc 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -96,5 +96,6 @@ describe('core production bootstrap', () => { const api = (window as unknown as { tsjs: TsjsApi }).tsjs; expect(api._internal).toMatchObject({ state: 'fallback', reason: 'abi_mismatch' }); expect(api).not.toHaveProperty('diagnostics'); + expect(api).not.toHaveProperty('_integrationConfig'); }); }); diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 4940300a8..f9dcfbac9 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -23,17 +23,9 @@ NODE_VERSION="$(grep '^nodejs ' .tool-versions | awk '{print $2}')" FRAMEWORKS_VALUE="${TS_BROWSER_FRAMEWORKS:-nextjs wordpress}" FRAMEWORKS_VALUE="${FRAMEWORKS_VALUE//,/ }" read -r -a FRAMEWORKS <<< "$FRAMEWORKS_VALUE" -APS_V1_VALUE="${TS_TEST_APS_V1:-0}" -APS_V1_FEATURE_ARGS=() - -case "$APS_V1_VALUE" in - 0) ;; - 1) APS_V1_FEATURE_ARGS=(--features aps-runner-proxy-integration-test) ;; - *) - echo "TS_TEST_APS_V1 must be exactly 0 or 1" >&2 - exit 1 - ;; -esac +PROJECTS_VALUE="${TS_BROWSER_PROJECTS:-chromium}" +PROJECTS_VALUE="${PROJECTS_VALUE//,/ }" +read -r -a BROWSER_PROJECTS <<< "$PROJECTS_VALUE" if [ -z "$NODE_VERSION" ]; then echo "Failed to detect Node.js version from .tool-versions" >&2 @@ -45,6 +37,11 @@ if [ "${#FRAMEWORKS[@]}" -eq 0 ]; then exit 1 fi +if [ "${#BROWSER_PROJECTS[@]}" -eq 0 ]; then + echo "TS_BROWSER_PROJECTS must select at least one browser" >&2 + exit 1 +fi + for framework in "${FRAMEWORKS[@]}"; do case "$framework" in nextjs|wordpress) ;; @@ -55,6 +52,16 @@ for framework in "${FRAMEWORKS[@]}"; do esac done +for project in "${BROWSER_PROJECTS[@]}"; do + case "$project" in + chromium|firefox|webkit) ;; + *) + echo "Unsupported browser project: $project" >&2 + exit 1 + ;; + esac +done + # --- Build WASM binary --- echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ @@ -62,8 +69,7 @@ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ TRUSTED_SERVER__EC__PARTNERS='[{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"},{"name":"Integration Test Partner 2","source_domain":"inttest2.example.com","bidstream_enabled":true,"api_token":"integration-test-token-bravo-32-bytes-ok"}]' \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ - cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 \ - "${APS_V1_FEATURE_ARGS[@]}" + cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 echo "==> Generating Viceroy configs..." INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-configs.sh @@ -87,7 +93,12 @@ done # --- Install Playwright --- echo "==> Installing Playwright dependencies..." npm --prefix "$BROWSER_DIR" ci -npm --prefix "$BROWSER_DIR" exec -- playwright install chromium +PLAYWRIGHT_INSTALL_ARGS=(install) +if [ "${CI:-}" = "true" ]; then + PLAYWRIGHT_INSTALL_ARGS+=(--with-deps) +fi +npm --prefix "$BROWSER_DIR" exec -- playwright \ + "${PLAYWRIGHT_INSTALL_ARGS[@]}" "${BROWSER_PROJECTS[@]}" # --- Build browser-side Trusted Server and external Prebid fixtures --- echo "==> Building TSJS browser fixtures..." From 9f3278d3c7705e6e05750a5ed2481d7f849949d2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:08:41 -0700 Subject: [PATCH 411/494] Fix hard-cutover browser boot races --- .../trusted-server-core/src/html_processor.rs | 5 +- crates/trusted-server-core/src/publisher.rs | 10 +- crates/trusted-server-core/src/tsjs.rs | 8 +- .../tests/nextjs/gpt-diagnostics.spec.ts | 126 +++++++++++++----- .../browser/tests/shared/aps-renderer.spec.ts | 62 +++++---- .../lib/src/composition/browser.ts | 7 +- .../lib/src/kernel/integration_registry.ts | 47 ++++++- .../lib/src/shared/origin.ts | 23 ++++ .../lib/test/composition/browser.test.ts | 4 +- .../test/kernel/integration_registry.test.ts | 53 +++++++- .../lib/test/kernel/runtime.test.ts | 5 +- .../lib/test/shared/origin.test.ts | 39 ++++++ 12 files changed, 304 insertions(+), 85 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/shared/origin.test.ts diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 754ac5cb8..57d8e34b9 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -19,8 +19,7 @@ use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; -const EMPTY_AUCTION_PROJECTION_JSON: &str = - r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#; +const EMPTY_AUCTION_PROJECTION_JSON: &str = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#; /// Wraps [`HtmlRewriterAdapter`] with optional post-processing. /// @@ -1867,7 +1866,7 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains(r#""auctionId":"initial","results":[]},"bids":[]"#), + html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#), "should inject the exact safe empty initial projection" ); assert!(!html.contains(".bids=")); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 2f6e4496c..60c77d70c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -9059,7 +9059,7 @@ mod tests { } #[test] - fn stream_publisher_body_treats_mixed_case_html_as_html() { + fn stream_publisher_body_treats_mixed_case_html_as_hard_cutover_html() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -9099,12 +9099,12 @@ mod tests { let html = String::from_utf8(output).expect("should be valid UTF-8"); assert!( - html.contains(".adSlots=JSON.parse"), - "mixed-case HTML must use the HTML processor and inject ad slots. Got: {html}" + html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#), + "mixed-case HTML must use the HTML processor and inject the canonical boot projection. Got: {html}" ); assert!( - html.contains(".bids=JSON.parse"), - "mixed-case HTML must use the HTML processor and inject bids. Got: {html}" + !html.contains(".adSlots=JSON.parse") && !html.contains(".bids=JSON.parse"), + "mixed-case HTML must not restore legacy TSJS data globals. Got: {html}" ); } diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 7794f4f8d..590f1c6d3 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -280,7 +280,7 @@ mod tests { let script = tsjs_boot_script_v1(TsjsBootScriptConfigV1 { module_ids: &["creative", "gpt", "gpt_diagnostics"], auction_projection_json: - r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"bids":[]}"#, + r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#, creative: CreativeBootConfigV1 { enabled: true, click_guard: true, @@ -293,7 +293,7 @@ mod tests { assert!(script.starts_with(""#, - "should emit the slim-Prebid URL as a JSON-encoded string assignment" - ); - } - - #[cfg(any())] - #[test] - fn head_inserts_escapes_script_terminator_in_slim_prebid_url() { - // A configured URL containing `` must not close the inline tag. - let config = GptConfig { - slim_prebid_url: Some("https://cdn.example.com/x".to_string()), - ..test_config() - }; - let integration = GptIntegration::new(config); - let doc_state = IntegrationDocumentState::default(); - let ctx = IntegrationHtmlContext { - request_host: "edge.example.com", - request_scheme: "https", - origin_host: "example.com", - document_state: &doc_state, - }; - - let inserts = integration.head_inserts(&ctx); - - // The injected `` must be neutralised: the only - // `` left is the tag's own legitimate closer. - assert!( - !inserts[2].contains(" terminator, got: {}", - inserts[2] - ); - assert_eq!( - inserts[2].matches("").count(), - 1, - "only the tag's own closing should remain, got: {}", - inserts[2] - ); - assert!( - inserts[2].contains("<\\/script>"), - "should emit the escaped terminator, got: {}", - inserts[2] - ); - } - - #[cfg(any())] - #[test] - fn head_inserts_omits_slim_prebid_url_when_not_configured() { - let integration = GptIntegration::new(test_config()); - let doc_state = IntegrationDocumentState::default(); - let ctx = IntegrationHtmlContext { - request_host: "edge.example.com", - request_scheme: "https", - origin_host: "example.com", - document_state: &doc_state, - }; - - let inserts = integration.head_inserts(&ctx); - - assert_eq!( - inserts.len(), - 2, - "should emit exactly two head inserts when slim_prebid_url is absent" - ); - assert!( - inserts - .iter() - .all(|s| !s.contains("__tsjs_slim_prebid_url")), - "should not emit slim-Prebid URL tag when not configured" - ); - } - - #[test] - fn proposed_generated_fallback_is_stamped_but_not_the_production_bootstrap() { - let release = trusted_server_js::release_id(); - let proposed = proposed_gpt_bootstrap_fallback_js(); - - assert_eq!( - proposed.matches(release).count(), - 1, - "generated proposal should carry the exact release once" - ); - assert!( - proposed.contains("runtime_unavailable"), - "generated proposal should contain the terminal fallback shell" - ); - assert!( - !GPT_BOOTSTRAP_JS.contains(release), - "Task 8 must not replace the production GPT bootstrap before Task 19" - ); - } } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js deleted file mode 100644 index a57e5cf18..000000000 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ /dev/null @@ -1,495 +0,0 @@ -// Edge-injected GPT auction bootstrap. -// -// This is the minimal `window.tsjs.adInit` that runs on first page load -// before the TSJS bundle has had a chance to install its richer -// idempotent implementation. The bundle in -// crates/trusted-server-js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` -// once it loads. -// -// Contract with the bundle: -// - Both implementations must set `window.tsjs.servicesEnabled = true` -// after calling `enableSingleRequest()`/`enableServices()` so a -// subsequent call becomes a no-op. -// - `refresh()` is called only for the slots defined in this pass, -// never the global slot list. -// -// Only installed if `window.tsjs.adInit` isn't already defined. -(function () { - if (typeof window === "undefined") return; - var ts = (window.tsjs = window.tsjs || {}); - if (ts.adInit) return; - - // Track whether the publisher disabled GPT initial load. Read the effective - // googletag.getConfig() value when available, and wrap googletag.setConfig() - // and the legacy pubads().disableInitialLoad() method so changes are - // synchronized immediately and still detected when getConfig() is - // unavailable. With initial load disabled, display() only registers a slot - // and the ad request must come from a later refresh(); adInit() reads this to - // refresh its own freshly defined - // slots so they are not left blank. Pushed onto the command queue so it runs - // before the publisher's own GPT configuration. - function syncInitialLoadDisabled(gpt) { - if (typeof gpt.getConfig !== "function") return false; - var config = gpt.getConfig("disableInitialLoad"); - if (!config || typeof config.disableInitialLoad === "undefined") { - return false; - } - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; - return true; - } - - (window.googletag = window.googletag || { cmd: [] }).cmd.push(function () { - var gpt = window.googletag; - syncInitialLoadDisabled(gpt); - if ( - typeof gpt.setConfig === "function" && - !gpt.__tsInitialLoadConfigHooked - ) { - var originalSetConfig = gpt.setConfig.bind(gpt); - gpt.setConfig = function (config) { - var result = originalSetConfig.apply(gpt, arguments); - if ( - !syncInitialLoadDisabled(gpt) && - config && - "disableInitialLoad" in config - ) { - ts.gptInitialLoadDisabled = config.disableInitialLoad === true; - } - return result; - }; - gpt.__tsInitialLoadConfigHooked = true; - } - - var pubads = gpt.pubads && gpt.pubads(); - if ( - !pubads || - typeof pubads.disableInitialLoad !== "function" || - pubads.__tsInitialLoadHooked - ) { - return; - } - var originalDisableInitialLoad = pubads.disableInitialLoad.bind(pubads); - pubads.disableInitialLoad = function () { - var result = originalDisableInitialLoad.apply(pubads, arguments); - if (!syncInitialLoadDisabled(gpt)) { - ts.gptInitialLoadDisabled = true; - } - return result; - }; - pubads.__tsInitialLoadHooked = true; - }); - - function findSlotByElementId(pubads, elementId) { - var slots = pubads.getSlots ? pubads.getSlots() : []; - return ( - slots.find(function (slot) { - return slot.getSlotElementId() === elementId; - }) || null - ); - } - - function normalizedGptFormats(formats) { - return formats.length === 2 && - formats.every(function (format) { - return typeof format === "number"; - }) - ? [formats] - : formats; - } - - function handoffFormatsMatch(handoff, formats) { - return ( - JSON.stringify(handoff.formats) === - JSON.stringify(normalizedGptFormats(formats)) - ); - } - - function matchingHandoff(pubads, adUnitPath, formats, elementId) { - var exact = ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if (exact) return exact.publisherClaimed ? null : exact; - - var candidates = Object.values(ts.gptSlotHandoffs || {}).filter( - function (handoff, index, allHandoffs) { - return ( - allHandoffs.indexOf(handoff) === index && - !handoff.publisherClaimed && - !document.getElementById(handoff.slotElementId) && - elementId.startsWith(handoff.divIdPrefix) && - handoff.gamUnitPath === adUnitPath && - handoffFormatsMatch(handoff, formats) && - findSlotByElementId(pubads, handoff.slotElementId) - ); - }, - ); - return candidates.length === 1 ? candidates[0] : null; - } - - function displayTargetElementId(target) { - if (typeof target === "string") return target; - if (target && typeof target.getSlotElementId === "function") { - return target.getSlotElementId(); - } - return target && target.id ? target.id : null; - } - - function isElementVisible(element) { - var style = window.getComputedStyle(element); - return ( - style.display !== "none" && - style.visibility !== "hidden" && - style.visibility !== "collapse" - ); - } - - function slotElementHasLayout(element) { - if (!isElementVisible(element)) return false; - var elementRect = element.getBoundingClientRect(); - if (elementRect.width > 0 && elementRect.height > 0) return true; - - var container = document.getElementById(element.id + "-container"); - if (!container || !isElementVisible(container)) return false; - var containerRect = container.getBoundingClientRect(); - return containerRect.width > 0 && containerRect.height > 0; - } - - function findSlotElementByDivId(divId) { - if (!divId) return null; - var exact = document.getElementById(divId); - if (exact) return exact; - - var idElements = document.querySelectorAll("[id]"); - var prefixMatches = []; - for (var i = 0; i < idElements.length; i++) { - var candidate = idElements[i]; - if ( - candidate.id.startsWith(divId) && - !candidate.id.endsWith("-container") - ) { - prefixMatches.push(candidate); - } - } - // A unique prefix match may be a lazy slot that has not been sized yet. - // Geometry is only needed to disambiguate multiple responsive siblings. - if (prefixMatches.length === 1) return prefixMatches[0]; - - var visibleMatches = prefixMatches.filter(isElementVisible); - if (visibleMatches.length === 1) return visibleMatches[0]; - - var activeMatches = visibleMatches.filter(slotElementHasLayout); - if (activeMatches.length === 1) return activeMatches[0]; - - if ( - prefixMatches.length > 1 && - ts.log && - typeof ts.log.warn === "function" - ) { - ts.log.warn("GPT slot prefix did not resolve to one active element", { - divId: divId, - prefixMatchCount: prefixMatches.length, - activeMatchCount: activeMatches.length, - }); - } - return null; - } - - function runHandoffInternal(callback) { - var wasInternal = ts.gptSlotHandoffInternal; - ts.gptSlotHandoffInternal = true; - try { - return callback(); - } finally { - ts.gptSlotHandoffInternal = wasInternal; - } - } - - // TS cannot wait an arbitrary amount of time for a framework to define a - // slot: publishers that never define one would render blank. Instead, TS - // defines its fallback on the actual inner div and aliases only a later - // publisher defineSlot() for that exact div, or a hydration-renamed replacement - // after the original div is gone, to the same GPT slot. - function installSlotHandoff() { - window.googletag.cmd.push(function () { - var tag = window.googletag; - var pubads = tag.pubads && tag.pubads(); - if (!tag.defineSlot || !tag.display || !pubads) return; - - if (!tag.defineSlot.__tsSlotHandoffPatched) { - var originalDefineSlot = tag.defineSlot.bind(tag); - var patchedDefineSlot = function (adUnitPath, formats, elementId) { - if (!ts.gptSlotHandoffInternal && typeof elementId === "string") { - var handoff = matchingHandoff( - pubads, - adUnitPath, - formats, - elementId, - ); - if (handoff) { - var existingSlot = findSlotByElementId( - pubads, - handoff.slotElementId, - ); - if (existingSlot) { - ts.gptSlotHandoffs[elementId] = handoff; - handoff.publisherClaimed = true; - // The supported publisher lifecycle is defineSlot → addService → display. - // Intentionally wait for that display instead of applying a time heuristic. - handoff.suppressPublisherDisplay = true; - handoff.suppressPublisherRefresh = - ts.gptInitialLoadDisabled === true; - ts.prevGptSlots = (ts.prevGptSlots || []).filter( - function (ownedSlot) { - return ownedSlot !== existingSlot; - }, - ); - if ( - handoff.gamUnitPath !== adUnitPath || - !handoffFormatsMatch(handoff, formats) - ) { - ts.log && - ts.log.warn && - ts.log.warn( - "GPT slot handoff: publisher definition differs from TS configuration", - elementId, - ); - } - return existingSlot; - } - } - } - return elementId === undefined - ? originalDefineSlot(adUnitPath, formats) - : originalDefineSlot(adUnitPath, formats, elementId); - }; - patchedDefineSlot.__tsSlotHandoffPatched = true; - tag.defineSlot = patchedDefineSlot; - } - - if (!tag.display.__tsSlotHandoffPatched) { - var originalDisplay = tag.display.bind(tag); - var patchedDisplay = function (target) { - var elementId = displayTargetElementId(target); - var handoff = - elementId && ts.gptSlotHandoffs && ts.gptSlotHandoffs[elementId]; - if ( - !ts.gptSlotHandoffInternal && - handoff && - handoff.suppressPublisherDisplay - ) { - handoff.suppressPublisherDisplay = false; - return; - } - originalDisplay(target); - }; - patchedDisplay.__tsSlotHandoffPatched = true; - tag.display = patchedDisplay; - } - - if (!pubads.refresh.__tsSlotHandoffPatched) { - var originalRefresh = pubads.refresh.bind(pubads); - var callRefresh = function (slots, options) { - if (options === undefined) { - originalRefresh(slots); - } else { - originalRefresh(slots, options); - } - }; - var patchedRefresh = function (requestedSlots, options) { - if (ts.gptSlotHandoffInternal) { - callRefresh(requestedSlots, options); - return; - } - var slots = - requestedSlots || (pubads.getSlots ? pubads.getSlots() : null); - if (!slots) { - callRefresh(requestedSlots, options); - return; - } - var suppressed = false; - var remainingSlots = slots.filter(function (slot) { - var handoff = - ts.gptSlotHandoffs && ts.gptSlotHandoffs[slot.getSlotElementId()]; - if (!handoff || !handoff.suppressPublisherRefresh) return true; - handoff.suppressPublisherRefresh = false; - suppressed = true; - return false; - }); - if (!suppressed) { - callRefresh(requestedSlots, options); - } else if (remainingSlots.length > 0) { - callRefresh(remainingSlots, options); - } - }; - patchedRefresh.__tsSlotHandoffPatched = true; - pubads.refresh = patchedRefresh; - } - }); - } - - installSlotHandoff(); - - // Minimal fallback for tsjs.scheduleInitialAdInit, mirroring the bundle's - // hydration-safe scheduler in - // crates/trusted-server-js/lib/src/integrations/gpt/index.ts: the - // bids script hands the SSR bids payload to this scheduler, which applies - // it and runs adInit only while the page is still on navigation - // generation 0 (the SSR document), after window load plus a double - // requestAnimationFrame so the call lands outside React's hydration - // window. Keeps initial server-side ads working when the main TSJS bundle - // fails to load; the bundle overwrites this with the full implementation. - // - // Hidden documents: rAF is not serviced while the document is hidden, so a - // background-tab load holds the initial adInit until first view. Intended, - // and deliberately identical to the bundle scheduler — the impression is - // spent on a viewed tab, and the post-hydration guarantee holds whenever - // the request is actually issued. - ts.scheduleInitialAdInit = function (initialBids) { - if ((ts.navGeneration || 0) !== 0) return; - if (initialBids) ts.bids = initialBids; - var fire = function () { - if ((ts.navGeneration || 0) !== 0) return; - if (typeof ts.adInit === "function") ts.adInit(); - }; - var afterFrames = function () { - window.requestAnimationFrame(function () { - window.requestAnimationFrame(fire); - }); - }; - if (document.readyState === "complete") afterFrames(); - else window.addEventListener("load", afterFrames, { once: true }); - }; - - ts.adInit = function () { - var slots = ts.adSlots || []; - var bids = ts.bids || {}; - var divToSlotId = {}; - // Generation this invocation belongs to. The slot work below is queued on - // googletag.cmd, which drains only when GPT loads; recheck first inside - // the queued callback so a navigation committed in the gap cancels the - // stale mutation — mirrors the bundle's adInit. - var generation = ts.navGeneration || 0; - - googletag.cmd.push(function () { - if ((ts.navGeneration || 0) !== generation) return; - // Slots TS defined itself — tracked for SPA destroy. Publisher-owned - // slots are reused but never destroyed by TS on navigation. - var newSlots = []; - // Publisher-owned slots TS reused — refreshed to pick up server-side - // targeting. The publisher already display()ed these. - var slotsToRefresh = []; - // Element IDs of slots TS defined itself. GPT requires display() to - // register/render a freshly-defined slot; refresh() alone no-ops for a - // slot that was never displayed, so these are display()ed instead. - var slotsToDisplay = []; - slots.forEach(function (slot) { - // Resolve actual div ID: exact match first, then the one active prefix - // match. Responsive publishers may emit several mutually exclusive - // siblings for one stable prefix, so document order is not sufficient. - var el = findSlotElementByDivId(slot.div_id); - if (!el) return; - var actualDivId = el.id; - var b = bids[slot.id] || {}; - - var existingSlots = googletag.pubads().getSlots(); - var s = - existingSlots.find(function (gs) { - return gs.getSlotElementId() === actualDivId; - }) || null; - var tsOwned = false; - if (!s) { - // Define TS's fallback on the publisher's actual div. The scoped - // handoff wrapper returns this slot if the publisher defines it later. - s = runHandoffInternal(function () { - return googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - actualDivId, - ); - }); - if (!s) return; - s.addService(googletag.pubads()); - tsOwned = true; - ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; - ts.gptSlotHandoffs[actualDivId] = { - gamUnitPath: slot.gam_unit_path, - formats: slot.formats, - divIdPrefix: slot.div_id, - slotElementId: actualDivId, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - } - - Object.entries(slot.targeting || {}).forEach(function (e) { - s.setTargeting(e[0], e[1]); - }); - [ - "hb_pb", - "hb_bidder", - "hb_adid", - "hb_cache_host", - "hb_cache_path", - ].forEach(function (k) { - if (b[k]) s.setTargeting(k, b[k]); - }); - // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts - s.setTargeting("ts_initial", "1"); - // Map the resolved inner div to the slot ID. This bootstrap fires no - // beacons and registers no slotRenderEnded listener; the map is consumed - // by the bundle's render bridge (index.ts) once it loads. - divToSlotId[actualDivId] = slot.id; - var slotElementId = s.getSlotElementId(); - if (slotElementId && slotElementId !== actualDivId) { - divToSlotId[slotElementId] = slot.id; - } - if (tsOwned) { - newSlots.push(s); - var displayId = s.getSlotElementId() || actualDivId; - slotsToDisplay.push(displayId); - } else { - slotsToRefresh.push(s); - } - }); - ts.prevGptSlots = newSlots; - ts.divToSlotId = divToSlotId; - if (!ts.servicesEnabled) { - googletag.pubads().enableSingleRequest(); - googletag.enableServices(); - ts.servicesEnabled = true; - } - // Register and render TS-defined slots. GPT requires display() for a - // freshly-defined slot; without it the slot no-ops and misses its - // impression. Runs after enableServices(); on SPA navigation services are - // already enabled, so this runs unconditionally for new slots. - slotsToDisplay.forEach(function (divId) { - runHandoffInternal(function () { - googletag.display(divId); - }); - }); - // Reused publisher-owned slots always need a refresh to pick up the - // server-side targeting. TS-defined slots are fetched by display() above - // unless the publisher disabled initial load, in which case display() only - // registers them and refresh() must request the ad — otherwise they render - // blank. Only add them in that case to avoid double-requesting. - syncInitialLoadDisabled(window.googletag); - var slotsNeedingRefresh = ts.gptInitialLoadDisabled - ? slotsToRefresh.concat(newSlots) - : slotsToRefresh; - if (slotsNeedingRefresh.length > 0) { - // One-shot bypass: this internal refresh delivers the just-applied - // server-side targeting to GAM. If slim-Prebid has already wrapped - // refresh(), it must pass this call straight through — not clear the - // targeting and run a duplicate client-side auction. Mirrors the - // bundle's adInit() in crates/trusted-server-js/lib/src/integrations/gpt/index.ts. - ts.adInitRefreshInProgress = true; - try { - runHandoffInternal(function () { - googletag.pubads().refresh(slotsNeedingRefresh); - }); - } finally { - ts.adInitRefreshInProgress = false; - } - } - }); - }; -})(); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index a5d677836..fe0226d51 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -205,9 +205,8 @@ pub struct PrebidIntegrationConfig { pub enabled: bool, #[validate(url)] pub server_url: String, - /// Prebid Server account ID, injected into the client-side bundle via - /// `window.__tsjs_prebid.accountId` so publishers don't need to configure - /// it in JavaScript. + /// Prebid Server account ID delivered through the release-bound immutable + /// integration configuration so publishers do not configure it in JavaScript. #[serde(default)] pub account_id: Option, #[serde(default = "default_timeout_ms")] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 60c77d70c..d1b90740e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21,20 +21,14 @@ //! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for //! the streaming route). It is not a content-rewriting concern. -use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Duration; -use brotli::Decompressor; -use brotli::enc::BrotliEncoderParams; -use brotli::enc::writer::CompressorWriter; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use flate2::read::ZlibDecoder; -use flate2::write::{GzEncoder, ZlibEncoder}; use futures::StreamExt as _; use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; @@ -73,8 +67,8 @@ use crate::response_privacy::CDN_CACHE_HEADERS; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{ - BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig, - STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, + BodyStreamDecoder, BodyStreamEncoder, Compression, PipelineConfig, STREAM_CHUNK_SIZE, + StreamProcessor, StreamingPipeline, }; use crate::streaming_replacer::create_url_replacer; @@ -706,253 +700,6 @@ impl Drop for DispatchedAuctionGuard { } } -/// Mutable auction-hold state threaded through the streaming hold pipeline. -struct AuctionHoldState { - hold: Option, - dispatched: DispatchedAuctionGuard, - telemetry: AuctionTelemetryCarry, -} - -impl AuctionHoldState { - fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self { - Self { - hold: Some(BodyCloseHoldBuffer::new()), - dispatched, - telemetry, - } - } -} - -/// Abandon the in-flight auction (if still pending) with the given telemetry -/// reason. No-op once the auction has been collected or already abandoned. -async fn abandon_hold_auction( - state: &mut AuctionHoldState, - services: &RuntimeServices, - reason: &'static str, -) { - if let Some(dispatched) = state.dispatched.take() { - emit_abandoned_auction( - services, - state.telemetry.observation.take(), - dispatched, - reason, - ) - .await; - // Abandonment with telemetry is a terminal result, so the drop warning - // is no longer warranted. (A drop *during* the emit above still fires - // it, since the guard stays armed until here.) - state.dispatched.disarm(); - } -} - -/// Output of a single close-body hold step, split at the auction-collection -/// barrier. -/// -/// `ready` is the prefix the caller must emit *before* collecting the auction, -/// so a small page whose `` lands in the first source chunk still -/// streams its document prefix immediately instead of stalling behind the -/// auction. `close_found` signals that `, - close_found: bool, -} - -/// Feed one decoded chunk through the close-body hold and processor. -/// -/// Returns the ready prefix for the caller to emit — written to a client stream -/// by [`body_close_hold_loop_stream`], yielded from the lazy body by -/// [`publisher_response_into_streaming_response`]. Both async hold paths share -/// this function so their behavior cannot drift apart. -/// -/// This step never awaits auction collection: it processes only the bytes the -/// hold buffer releases as ready and reports whether `( - processor: &mut P, - encoder: &mut BodyStreamEncoder, - chunk: &[u8], - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result> { - let mut ready = Vec::new(); - let bytes: Cow<'_, [u8]> = match state.hold.as_mut() { - // Once the hold has been released the chunk streams straight through, - // borrowed rather than copied. - None => Cow::Borrowed(chunk), - Some(hold_buffer) => Cow::Owned(hold_buffer.push(chunk)), - }; - match process_and_encode_chunk(processor, encoder, &bytes, false, "Failed to process chunk") { - Ok(Some(encoded)) => ready.push(encoded), - Ok(None) => {} - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; - return Err(err); - } - } - let close_found = state - .hold - .as_ref() - .is_some_and(BodyCloseHoldBuffer::found_close); - Ok(HoldStepSegments { ready, close_found }) -} - -/// Collect the dispatched auction and process the held `` tail. -/// -/// Call only after [`hold_step_decoded_chunk`] (or -/// [`hold_finish_ready_segments`]) reports `close_found` and the ready prefix -/// has already been emitted: -/// collecting here — after the prefix streams — is what keeps the auction -/// riding alongside transfer instead of blocking it. Collection runs before the -/// tail is processed so `lol_html` sees live bids at the injection point. -async fn hold_collect_close_tail( - processor: &mut P, - encoder: &mut BodyStreamEncoder, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let mut segments = Vec::new(); - let dispatched = state - .dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, state.telemetry.take(), collect_refs).await; - // Collection reached a terminal result; disarm only now so a drop while the - // collect await above was still pending is reported. - state.dispatched.disarm(); - - let held = state - .hold - .take() - .expect("should have close-body hold buffer") - .finish(); - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &held, - false, - "Failed to process held body close", - )? { - segments.push(encoded); - } - Ok(segments) -} - -/// Pull and decode the next chunk of the close-body hold pipeline, feeding it -/// through [`hold_step_decoded_chunk`]. -/// -/// Returns `Ok(None)` when the source is exhausted; the caller must then emit -/// [`hold_finish_ready_segments`] followed by [`hold_finish_tail_segments`]. On -/// read or decode failure the pending auction is -/// abandoned before the error is returned. Shared by the write-sink driver -/// ([`body_close_hold_loop_stream`]) and the lazy publisher body stream so -/// the two hold paths cannot drift apart. -async fn hold_step_next_chunk( - source: &mut BodyChunkSource, - decoder: &mut BodyStreamDecoder, - encoder: &mut BodyStreamEncoder, - processor: &mut P, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let raw_chunk = match source.next_chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => return Ok(None), - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_read_error").await; - return Err(err); - } - }; - let decoded = match decoder.decode_chunk(raw_chunk) { - Ok(decoded) => decoded, - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; - return Err(err); - } - }; - if decoded.is_empty() { - return Ok(Some(HoldStepSegments { - ready: Vec::new(), - close_found: false, - })); - } - hold_step_decoded_chunk(processor, encoder, &decoded, state, collect_refs) - .await - .map(Some) -} - -/// Drain the decoder tail at end of the origin stream, returning the prefix the -/// caller must emit before [`hold_finish_tail_segments`]. -/// -/// A codec can hold document bytes back until its own finalization — the gzip -/// decoder releases the remainder of the final member at `finish()` — and that -/// remainder may be the whole document for a small page. Returning it ahead of -/// collection keeps the invariant the mid-stream path already has: only the -/// closing `` tail waits for the auction, never renderable content. -/// -/// On decoder failure the pending auction is abandoned before the error is -/// returned. -async fn hold_finish_ready_segments( - processor: &mut P, - decoder: &mut BodyStreamDecoder, - encoder: &mut BodyStreamEncoder, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let decoded_tail = match decoder.finish() { - Ok(decoded_tail) => decoded_tail, - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; - return Err(err); - } - }; - if decoded_tail.is_empty() { - return Ok(Vec::new()); - } - let step = - hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?; - Ok(step.ready) -} - -/// Finalize the close-body hold pipeline after [`hold_finish_ready_segments`]. -/// -/// Collects the auction if the close-body tag never streamed, processes the held -/// tail plus the processor's final chunk, and emits the encoder trailer. Returns -/// the encoded segments for the caller to emit. -async fn hold_finish_tail_segments( - processor: &mut P, - encoder: &mut BodyStreamEncoder, - state: &mut AuctionHoldState, - collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { - let mut segments = Vec::new(); - - // If the hold is still armed the auction was never collected mid-stream: - // `` arrived only in the decoder tail, or the document had none at - // all. Collect now and flush the held remainder before finalizing. - if state.hold.is_some() { - segments.extend(hold_collect_close_tail(processor, encoder, state, collect_refs).await?); - } - - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &[], - true, - "Failed to finalize processor", - )? { - segments.push(encoded); - } - let trailer = encoder.finish()?; - if !trailer.is_empty() { - segments.push(bytes::Bytes::from(trailer)); - } - Ok(segments) -} - /// Create a unified HTML stream processor. /// /// Builds the config via [`HtmlProcessorConfig::from_settings`] and then @@ -1233,6 +980,12 @@ pub async fn buffer_publisher_response_async( /// Returns an error if processor construction fails before the streaming body /// is created; a dispatched auction is abandoned with `processor_init_error` /// telemetry first, matching the buffered finalizer. +/// +/// # Panics +/// +/// Panics if an internal streaming auction guard is missing after this helper +/// has committed to collecting it. That state indicates a violated internal +/// ownership invariant. pub async fn publisher_response_into_streaming_response( publisher_response: PublisherResponse, method: &Method, @@ -1989,22 +1742,6 @@ struct AuctionTelemetryCarry { auction_request: Option, } -impl AuctionTelemetryCarry { - fn take(&mut self) -> Self { - Self { - observation: self.observation.take(), - auction_request: self.auction_request.take(), - } - } -} - -/// Bundles the auction-collection state passed through the streaming helpers. -struct AuctionCollectCtx<'a> { - dispatched: DispatchedAuction, - telemetry: AuctionTelemetryCarry, - deps: AuctionCollectDeps<'a>, -} - /// Borrowed dependencies of the auction collect step. /// /// Split from the per-auction state above because `dispatched` and `telemetry` @@ -2021,334 +1758,6 @@ struct AuctionCollectDeps<'a> { request_origin: String, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw `( - body: EdgeBody, - output: &mut W, - processor: &mut P, - compression: Compression, - ctx: AuctionCollectCtx<'_>, -) -> Result<(), Report> { - if body.is_stream() { - let max_body_bytes = ctx.deps.settings.publisher.max_buffered_body_bytes; - return body_close_hold_loop_stream( - body, - output, - processor, - compression, - ctx, - max_body_bytes, - ) - .await; - } - - // Bound the gzip decode budget to the same ceiling the buffered writer - // enforces, matching the streaming arm above and the no-hold buffered path. - let max_body_bytes = ctx.deps.settings.publisher.max_buffered_body_bytes; - let body = body_as_reader(body)?; - match compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, - Compression::Gzip => { - // `GzipDecodeReader` decodes concatenated gzip members (RFC 1952) - // and bounds decoded output, unlike `flate2::read::GzDecoder`, which - // silently drops every member after the first — dropping trailing - // markup (potentially including ``) on buffered adapters. - let decoder = GzipDecodeReader::new(body, max_body_bytes); - let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; - encoder.finish().change_context(TrustedServerError::Proxy { - message: "Failed to finalize gzip encoder".to_string(), - })?; - Ok(()) - } - Compression::Deflate => { - let decoder = ZlibDecoder::new(body); - let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; - encoder.finish().change_context(TrustedServerError::Proxy { - message: "Failed to finalize deflate encoder".to_string(), - })?; - Ok(()) - } - Compression::Brotli => { - let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); - let params = BrotliEncoderParams { - quality: 4, - lgwin: 22, - ..Default::default() - }; - let mut encoder = - CompressorWriter::with_params(&mut *output, STREAM_CHUNK_SIZE, ¶ms); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; - let _ = encoder.into_inner(); - Ok(()) - } - } -} - -/// Async-pull variant of [`body_close_hold_loop`] for live origin streams. -/// -/// Shares [`hold_step_next_chunk`] and the finish stages with the -/// lazy streaming body built by [`publisher_response_into_streaming_response`], -/// so the two async hold paths cannot drift apart. -/// -/// No production caller reaches this today: it is only entered through -/// [`buffer_publisher_response_async`], and the buffered adapters (Axum, -/// Cloudflare, Spin) never produce `Body::Stream` because the publisher fetch -/// is gated on `supports_streaming_responses()`. It is groundwork for those -/// adapters' streaming cutover; Fastly uses the lazy stream instead. -async fn body_close_hold_loop_stream( - body: EdgeBody, - writer: &mut W, - processor: &mut P, - compression: Compression, - ctx: AuctionCollectCtx<'_>, - max_body_bytes: usize, -) -> Result<(), Report> { - let AuctionCollectCtx { - dispatched, - telemetry, - deps: collect_refs, - } = ctx; - let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); - let mut encoder = BodyStreamEncoder::new(compression); - let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); - let mut state = AuctionHoldState::new(DispatchedAuctionGuard::new(dispatched), telemetry); - - while let Some(step) = hold_step_next_chunk( - &mut source, - &mut decoder, - &mut encoder, - processor, - &mut state, - &collect_refs, - ) - .await? - { - // Write the ready prefix before collecting the auction, matching the - // lazy Fastly stream: only the held `` tail waits on collection. - for encoded in step.ready { - write_encoded_segment(writer, &encoded)?; - } - if step.close_found { - for encoded in - hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? - { - write_encoded_segment(writer, &encoded)?; - } - } - } - - // Write the decoder-finalized prefix before collection, matching the lazy - // Fastly stream: only the held `` tail waits on the auction. - for encoded in hold_finish_ready_segments( - processor, - &mut decoder, - &mut encoder, - &mut state, - &collect_refs, - ) - .await? - { - write_encoded_segment(writer, &encoded)?; - } - for encoded in - hold_finish_tail_segments(processor, &mut encoder, &mut state, &collect_refs).await? - { - write_encoded_segment(writer, &encoded)?; - } - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output".to_string(), - })?; - Ok(()) -} - -const BODY_CLOSE_PREFIX: &[u8] = b", - found_close: bool, -} - -impl BodyCloseHoldBuffer { - fn new() -> Self { - Self { - buffered: Vec::new(), - found_close: false, - } - } - - fn push(&mut self, chunk: &[u8]) -> Vec { - self.buffered.extend_from_slice(chunk); - - if self.found_close { - return Vec::new(); - } - - if let Some(pos) = find_ascii_case_insensitive(&self.buffered, BODY_CLOSE_PREFIX) { - self.found_close = true; - return self.buffered.drain(..pos).collect(); - } - - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); - if self.buffered.len() <= keep_len { - return Vec::new(); - } - - let split_at = self.buffered.len() - keep_len; - self.buffered.drain(..split_at).collect() - } - - fn found_close(&self) -> bool { - self.found_close - } - - fn finish(self) -> Vec { - self.buffered - } -} - -fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|window| { - window - .iter() - .zip(needle) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) - }) -} - -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive `( - mut reader: R, - writer: &mut W, - processor: &mut P, - ctx: AuctionCollectCtx<'_>, -) -> Result<(), Report> { - let AuctionCollectCtx { - dispatched, - mut telemetry, - deps, - } = ctx; - let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); - let mut dispatched = Some(dispatched); - - loop { - match reader.read(&mut buffer) { - Ok(0) => { - if let Some(hold) = hold.take() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, telemetry.take(), &deps).await; - - let held = hold.finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - // Signal EOF to lol_html (fires end() which flushes remaining state). - let final_out = processor.process_chunk(&[], true).change_context( - TrustedServerError::Proxy { - message: "Failed to finalize processor".to_string(), - }, - )?; - if !final_out.is_empty() { - writer - .write_all(&final_out) - .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; - } - break; - } - Ok(n) => { - if let Some(hold_buffer) = hold.as_mut() { - let ready = hold_buffer.push(&buffer[..n]); - if let Err(err) = write_processed_chunk( - writer, - processor, - &ready, - false, - "Failed to process chunk", - "Failed to write chunk", - ) { - if let Some(dispatched) = dispatched.take() { - emit_abandoned_auction( - deps.services, - telemetry.observation.take(), - dispatched, - "stream_process_error", - ) - .await; - } - return Err(err); - } - - if hold_buffer.found_close() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, telemetry.take(), &deps).await; - - let held = hold - .take() - .expect("should have close-body hold buffer") - .finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; - } - } - Err(e) => { - if let Some(dispatched) = dispatched.take() { - emit_abandoned_auction( - deps.services, - telemetry.observation.take(), - dispatched, - "stream_read_error", - ) - .await; - } - return Err(Report::new(TrustedServerError::Proxy { - message: format!("Failed to read origin body: {e}"), - })); - } - } - } - - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output".to_string(), - })?; - Ok(()) -} - async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -2418,7 +1827,7 @@ async fn collect_non_html_auction( } } -// Private orchestration helper called only from `body_close_hold_loop`. +// Private orchestration helper used by the streaming response paths. // `dispatched` and `telemetry` are moved per collect, so they stay by value // while the rest of the context is borrowed. async fn collect_stream_auction( @@ -2435,14 +1844,14 @@ async fn collect_stream_auction( settings, request_origin, } = deps; - log::info!("body_close_hold_loop: collecting dispatched auction before held body tail"); + log::info!("streaming response: collecting dispatched auction"); let placeholder = mediator_placeholder_request(); let collect_ctx = make_collect_context(settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; log::info!( - "body_close_hold_loop: collect complete - {} winning bid(s)", + "streaming response: collect complete - {} winning bid(s)", result.winning_bids.len() ); let delivered_winner_slots = write_projection_to_state( @@ -2474,35 +1883,6 @@ async fn collect_stream_auction( } } -fn write_processed_chunk( - writer: &mut W, - processor: &mut P, - chunk: &[u8], - is_last: bool, - process_error: &str, - write_error: &str, -) -> Result<(), Report> { - if chunk.is_empty() && !is_last { - return Ok(()); - } - - let out = - processor - .process_chunk(chunk, is_last) - .change_context(TrustedServerError::Proxy { - message: process_error.to_string(), - })?; - if !out.is_empty() { - writer - .write_all(&out) - .change_context(TrustedServerError::Proxy { - message: write_error.to_string(), - })?; - } - - Ok(()) -} - /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -3215,38 +2595,6 @@ pub(crate) fn build_auction_request( } } -/// Escape a JSON string so it is safe to embed inside a JS double-quoted string literal -/// inside an HTML `` injection breaking out of the script context -/// - U+2028, U+2029 — line/paragraph separators that are valid JSON but terminate -/// a JS string literal in some parsers -/// -/// All substitutions use `\uXXXX` form, which is valid inside both JSON strings -/// and JS string literals. The result is always safe to write as `JSON.parse("…")`. -fn html_escape_for_script(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '<' => out.push_str("\\u003C"), - '>' => out.push_str("\\u003E"), - '&' => out.push_str("\\u0026"), - '\u{2028}' => out.push_str("\\u2028"), - '\u{2029}' => out.push_str("\\u2029"), - _ => out.push(ch), - } - } - out -} - -#[allow( - dead_code, - reason = "pure coordinated-cutover projection is wired to entry points in Task 19" -)] pub(crate) mod coordinated_cutover_v1 { use super::*; @@ -3465,335 +2813,6 @@ pub(crate) mod coordinated_cutover_v1 { } } -/// Build a price-bucketed bid map from winning bids. -/// -/// Returns a JSON object map of slot ID → bid metadata including the bucketed -/// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. -pub(crate) fn build_bid_map( - winning_bids: &std::collections::HashMap, - granularity: crate::price_bucket::PriceGranularity, - settings: &Settings, - request_origin: &str, - include_debug_bid: bool, -) -> serde_json::Map { - build_bid_map_with_auction_id( - winning_bids, - granularity, - settings, - request_origin, - include_debug_bid, - None, - ) -} - -fn build_bid_map_with_auction_id( - winning_bids: &std::collections::HashMap, - granularity: crate::price_bucket::PriceGranularity, - settings: &Settings, - request_origin: &str, - include_debug_bid: bool, - auction_id: Option<&str>, -) -> serde_json::Map { - // Inline creatives render in a foreign origin (PUC's srcdoc under GAM), so - // their proxy/click URLs must be absolute against the origin the visitor is - // actually on — scheme, host, and port. Fall back to the configured publisher - // domain only when the request origin is unknown (e.g. an empty host on a - // non-navigation path), where no inline render is expected anyway. - let base_origin = if request_origin.is_empty() { - format!("https://{}", settings.publisher.domain) - } else { - request_origin.to_owned() - }; - winning_bids - .iter() - .filter_map(|(slot_id, bid)| { - bid.price.map(|cpm| { - let bucket = price_bucket(cpm, granularity); - let mut obj = serde_json::Map::new(); - obj.insert("hb_pb".to_string(), serde_json::Value::String(bucket)); - obj.insert( - "hb_bidder".to_string(), - serde_json::Value::String(bid.bidder.clone()), - ); - // Winning creative dimensions — the bridge sizes the inline - // render from these, falling back to the first configured slot - // format only when absent, which mis-sizes a multi-size slot. - // Omit a zero dimension (missing OpenRTB w/h parse to 0) so the - // bridge falls back rather than sizing the frame to 0. - if bid.width > 0 { - obj.insert("w".to_string(), serde_json::Value::from(bid.width)); - } - if bid.height > 0 { - obj.insert("h".to_string(), serde_json::Value::from(bid.height)); - } - // PBS Cache remains highest priority. Typed renderer bids use - // their selected upstream bid ID as the Universal Creative key. - // - // `bid.bid_id` (the OpenRTB bid's own `id`) is the last resort: it is - // always present per spec but only unique per bid instance, not a - // creative identifier. It still satisfies what hb_adid needs here — - // a stable value GAM's Universal Creative echoes back verbatim so - // the render bridge can find this exact winning bid — for bidders - // that return neither a cache UUID nor `adid`. Without it those - // bids carry no hb_adid at all, so no targeting key reaches GAM and - // the render handshake can never start. - let renderer_bid_id = bid.renderer.as_ref().and(bid.bid_id.as_deref()); - let hb_adid = bid - .cache_id - .as_deref() - .or(renderer_bid_id) - .or(bid.ad_id.as_deref()) - .or(bid.bid_id.as_deref()); - if let Some(auction_id) = auction_id.filter(|id| !id.is_empty()) { - obj.insert( - "hb_auction_id".to_string(), - serde_json::Value::String(auction_id.to_string()), - ); - } - if let Some(bid_id) = bid.bid_id.as_ref() { - obj.insert( - "hb_bid_id".to_string(), - serde_json::Value::String(bid_id.clone()), - ); - } - if let Some(creative_id) = bid.creative_id.as_ref() { - obj.insert( - "hb_crid".to_string(), - serde_json::Value::String(creative_id.clone()), - ); - } - if let Some(id) = hb_adid { - obj.insert( - "hb_adid".to_string(), - serde_json::Value::String(id.to_string()), - ); - } - - // Win/billing notification URLs, fired verbatim by the bridge. - // Per OpenRTB these are the canonical carriers of - // `${AUCTION_PRICE}`, so expand it from the same winning CPM used - // for the creative below — an unexpanded macro would report an - // unresolved clearing price to the SSP, and some reject such - // notifications outright. - if let Some(ref nurl) = bid.nurl { - let nurl = crate::creative::expand_auction_price_macro(nurl, cpm); - obj.insert("nurl".to_string(), serde_json::Value::String(nurl)); - } - if let Some(ref burl) = bid.burl { - let burl = crate::creative::expand_auction_price_macro(burl, cpm); - obj.insert("burl".to_string(), serde_json::Value::String(burl)); - } - if let Some(ref renderer) = bid.renderer { - obj.insert( - "renderer".to_string(), - serde_json::to_value(renderer).expect("should serialize typed renderer"), - ); - } - // Always include the winning creative so the pbRender bridge can - // render it locally when GAM serves the Prebid Universal Creative - // — no PBS Cache round trip. - // - // Optionally sanitize dangerous markup, then optionally rewrite - // URLs to first-party proxies — the same opt-in creative-processing - // policy as the `/auction` path (see `auction::formats`), except - // for the inline render context. This `adm` is rendered by the - // Prebid Universal Creative inside GAM's iframe (`f.srcdoc = d.ad`), - // a foreign origin where root-relative `/first-party/…` URLs resolve - // against GAM and 404. The inline rewriter therefore emits - // absolute first-party URLs and omits the tsjs bundle injection. - // - // `None` means the bid carried no `creative` field at all; every - // `Some(raw)` — including an explicit empty string, which PBS can - // return — is a supplied creative and goes through processing, so - // an empty `adm` cannot masquerade as "absent" and re-enable the - // raw cache fallback below. Processing may reject the creative - // outright (empty output): sanitization can strip everything, - // parsing can fail, or the size cap can trip. - let processed_adm = bid.creative.as_ref().map(|raw_creative| { - // Resolve ${AUCTION_PRICE} from the exact winning CPM BEFORE - // sanitizing, rewriting, and signing — URL rewriting would - // otherwise encode the literal macro into the signed proxy/click - // URL, and signing would lock that wrong value. - let priced = crate::creative::expand_auction_price_macro(raw_creative, cpm); - crate::creative::process_inline_auction_creative( - settings, - &base_origin, - &priced, - ) - }); - // Cache endpoint coordinates — only present for PBS bids with - // Prebid Cache enabled, and only when the bid supplied no creative - // of its own. The Prebid Universal Creative constructs: - // https://?uuid= - // and renders the cached bid's ORIGINAL adm, bypassing every - // server-side processing policy. Emitting them alongside a - // supplied creative would therefore hand the client an - // unprocessed copy of markup we just sanitized, rewrote, or - // rejected — so they ship only for genuinely absent creatives, - // where they are the sole render source. - match processed_adm { - Some(adm) if !adm.is_empty() => { - obj.insert( - "hb_adm_hash".to_string(), - serde_json::Value::String(crate::auction::types::adm_trace_hash(&adm)), - ); - obj.insert("adm".to_string(), serde_json::Value::String(adm)); - } - Some(_) => { - log::warn!( - "build_bid_map: creative for slot {} bidder {} rejected by processing; suppressing PBS Cache fallback", - slot_id, - bid.bidder - ); - } - None => { - if let Some(ref host) = bid.cache_host { - obj.insert( - "hb_cache_host".to_string(), - serde_json::Value::String(host.clone()), - ); - } - if let Some(ref path) = bid.cache_path { - obj.insert( - "hb_cache_path".to_string(), - serde_json::Value::String(path.clone()), - ); - } - } - } - // Verbose per-bid debug blob only under the testing flag; also - // doubles as the client-side gate for the direct GAM-replace path. - // Deliberately mirrors the bidder-supplied `creative`/`nurl`/`burl` - // verbatim, macros unexpanded: this blob is diagnostic — nothing - // renders or fires from it — and showing what the bidder actually - // sent is the point. - if include_debug_bid { - obj.insert( - "debug_bid".to_string(), - serde_json::json!({ - "slot_id": bid.slot_id, - "price": bid.price, - "currency": bid.currency, - "creative": bid.creative, - "adomain": bid.adomain, - "bidder": bid.bidder, - "width": bid.width, - "height": bid.height, - "nurl": bid.nurl, - "burl": bid.burl, - "bid_id": bid.bid_id, - "ad_id": bid.ad_id, - "creative_id": bid.creative_id, - "cache_id": bid.cache_id, - "cache_host": bid.cache_host, - "cache_path": bid.cache_path, - "metadata": bid.metadata, - }), - ); - } - (slot_id.clone(), serde_json::Value::Object(obj)) - }) - }) - .collect() -} - -/// Build the `tsjs.bids` `` sequences inside the string. -pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { - let json = serde_json::to_string(bid_map) - .expect("serde_json::to_string of Map should be infallible"); - let escaped = html_escape_for_script(&json); - // adInit() defines GPT slots on the publisher's `-container` wrappers, which - // mutates those ad-slot subtrees. Calling it synchronously here (this script - // runs at body-parse time) lands those mutations inside React's hydration - // window and trips a #418 hydration mismatch. The deferral — gate on window - // `load`, then a double `requestAnimationFrame`, pinned to navigation - // generation 0 so a faster SPA navigation cancels it — lives in the GPT - // bundle module as `tsjs.scheduleInitialAdInit` - // (crates/trusted-server-js/lib/src/integrations/gpt/index.ts), where the - // lifecycle is executable under Vitest (schedule_initial_ad_init.test.ts) - // and the navigation-generation guard is shared with the SPA auction hook; - // gpt_bootstrap.js installs a minimal head-injected fallback so a failed - // bundle load still initializes initial ads. - // - // The deferral is deliberately unconditional — every publisher, every - // page — even though only hydrating React publishers exhibit the #418 - // failure. Uniform behavior keeps one code path to reason about and - // avoids a framework-detection or config surface that must be kept - // truthful per publisher; the cost is that non-React pages also move the - // initial request from parse time to window load. The agreed follow-up - // (branch 958-adinit-hydration-chunk-gate, spec in docs/superpowers/ - // specs/2026-07-24-adinit-hydration-gate-design.md) narrows the gate to - // the Next.js hydration chunks with `load` as the can't-hang fallback, - // which recovers most of that latency without a new config surface. - // - // The bids payload is handed to the scheduler instead of being assigned - // here: an SPA navigation that committed while this document was still - // streaming has already replaced `tsjs.bids`, and an unconditional - // assignment would clobber the live route's bids with the stale SSR - // payload. Only when no scheduler exists at all (GPT integration active - // without its head bootstrap — not an expected deployment) does the script - // fall back to a plain assignment, where no SPA hook exists to race with. - format!( - "", - escaped - ) -} - -/// Prospective hard-cutover mark emitted at the bids/projection boundary. -/// -/// Task 19 inserts this already-tested fragment into the production boot path in -/// the same atomic switch that installs the matching first-display mark. -#[allow( - dead_code, - reason = "Task 16 prepares this fragment for the atomic Task 19 production switch" -)] -pub(crate) fn build_bids_script_performance_mark() -> &'static str { - "(function(){try{window.performance.mark(\"tsjs:bids-script\");}catch(_){}})();" -} - -/// Builds the client-facing JSON wire shape for one creative-opportunity slot. -/// -/// Shared verbatim by [`build_ad_slots_script`] (initial page render) and -/// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single -/// definition and the two paths cannot silently diverge. Property names match -/// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit -/// path exceeds its rendering limit. -pub(crate) fn build_slot_json( - slot: &crate::creative_opportunities::CreativeOpportunitySlot, - co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, - section: &str, -) -> Option { - let gam_path = slot.render_gam_unit_path(&co_config.gam_network_id, section)?; - let div_id = slot.resolved_div_id(); - let formats: Vec = slot - .formats - .iter() - .map(|f| serde_json::json!([f.width, f.height])) - .collect(); - let targeting: serde_json::Map = slot - .targeting - .iter() - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) - .collect(); - Some(serde_json::json!({ - "id": slot.id, - "gam_unit_path": gam_path, - "div_id": div_id, - "formats": formats, - "targeting": targeting, - })) -} - /// Build the exact ordered GAM placement records carried by the browser projection. pub(crate) fn build_browser_slots_v1( matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], @@ -3865,31 +2884,6 @@ fn match_renderable_slots( .collect() } -/// Build the `tsjs.adSlots` `", - escaped - ) -} - /// Whether the content type requires processing (URL rewriting, HTML injection). /// /// Text-based and JavaScript/JSON responses are processable; binary types @@ -4155,9 +3149,8 @@ pub async fn handle_page_bids( // The [auction].enabled kill switch and a consent denial disable the entire // server-side ad stack. In those states the endpoint must return no slots, - // so the SPA hook does not assign `ts.adSlots` and call `adInit()` — - // otherwise the kill switch/consent gate would stop SSP calls but still let - // the client create/refresh GPT slots. Bot/prefetch requests, by contrast, + // so the coordinated runtime cannot create or refresh GPT placements. + // Bot/prefetch requests, by contrast, // keep their slot definitions (the placement structure is unchanged) but // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; @@ -4337,7 +3330,6 @@ pub async fn handle_page_bids( mod tests { use std::future::Future as _; use std::io::{self, Read as _, Write as _}; - use std::sync::atomic::{AtomicUsize, Ordering}; use brotli::Decompressor; use brotli::enc::writer::CompressorWriter; @@ -4990,47 +3982,6 @@ mod tests { } } - struct ChunkedReader { - chunks: std::collections::VecDeque>, - read_count: Arc, - } - - impl ChunkedReader { - fn new(chunks: &[&[u8]], read_count: Arc) -> Self { - Self { - chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), - read_count, - } - } - } - - impl io::Read for ChunkedReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let Some(chunk) = self.chunks.pop_front() else { - return Ok(0); - }; - self.read_count.fetch_add(1, Ordering::SeqCst); - let len = chunk.len().min(buf.len()); - buf[..len].copy_from_slice(&chunk[..len]); - Ok(len) - } - } - - struct RecordingProcessor { - read_count: Arc, - body_close_processed_at: Arc, - } - - impl StreamProcessor for RecordingProcessor { - fn process_chunk(&mut self, chunk: &[u8], _is_last: bool) -> Result, io::Error> { - if find_ascii_case_insensitive(chunk, BODY_CLOSE_PREFIX).is_some() { - self.body_close_processed_at - .store(self.read_count.load(Ordering::SeqCst), Ordering::SeqCst); - } - Ok(chunk.to_vec()) - } - } - fn gzip_encode(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder @@ -6659,186 +5610,6 @@ mod tests { ); } - #[tokio::test] - async fn body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks() { - let settings = create_test_settings(); - let services = noop_services(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); - let read_count = Arc::new(AtomicUsize::new(0)); - let body_close_processed_at = Arc::new(AtomicUsize::new(0)); - let reader = ChunkedReader::new( - &[ - b"painted", - b"", - b"", - ], - Arc::clone(&read_count), - ); - let mut processor = RecordingProcessor { - read_count: Arc::clone(&read_count), - body_close_processed_at: Arc::clone(&body_close_processed_at), - }; - let ad_bids_state = Arc::new(Mutex::new(None)); - let ctx = AuctionCollectCtx { - dispatched, - telemetry: AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - deps: AuctionCollectDeps { - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - browser_slots_json: None, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - request_origin: String::new(), - }, - }; - let mut output = Vec::new(); - - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); - - assert_eq!( - body_close_processed_at.load(Ordering::SeqCst), - 1, - "close-body tail should be processed as soon as it is found, before later chunks are read" - ); - assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" - ); - } - - #[tokio::test] - async fn hold_step_yields_ready_prefix_before_collecting_auction() { - // A small page whose `` lands in the first source chunk must - // still stream its document prefix immediately. `hold_step_decoded_chunk` - // reports the ready prefix and `close_found` without collecting; only - // `hold_collect_close_tail` awaits collection. Regression guard for the - // #849 FCP objective: the prefix must become ready while collection - // remains pending. - let settings = create_test_settings(); - let services = noop_services(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let ad_bids_state = Arc::new(Mutex::new(None)); - let mut state = AuctionHoldState::new( - DispatchedAuctionGuard::new(DispatchedAuction::empty_for_test( - test_auction_request(), - 500, - )), - AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - ); - let collect_refs = AuctionCollectDeps { - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - browser_slots_json: None, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - request_origin: String::new(), - }; - // Passthrough processor: the ordering contract is about collection, not - // HTML rewriting, so keep the emitted bytes verbatim. - let mut processor = RecordingProcessor { - read_count: Arc::new(AtomicUsize::new(0)), - body_close_processed_at: Arc::new(AtomicUsize::new(0)), - }; - let mut encoder = BodyStreamEncoder::new(Compression::None); - - let step = hold_step_decoded_chunk( - &mut processor, - &mut encoder, - b"painted", - &mut state, - &collect_refs, - ) - .await - .expect("hold step should succeed"); - - assert!( - step.close_found, - " in the first chunk must be detected" - ); - let ready: Vec = step.ready.iter().flat_map(|b| b.to_vec()).collect(); - assert_eq!( - std::str::from_utf8(&ready).expect("ready prefix should be utf8"), - "painted", - "the prefix up to must be ready before collection" - ); - assert!( - ad_bids_state - .lock() - .expect("should lock bid state") - .is_none(), - "auction must not be collected while the ready prefix is emitted" - ); - - let tail = hold_collect_close_tail(&mut processor, &mut encoder, &mut state, &collect_refs) - .await - .expect("collect should succeed"); - let tail_bytes: Vec = tail.iter().flat_map(|b| b.to_vec()).collect(); - assert_eq!( - std::str::from_utf8(&tail_bytes).expect("held tail should be utf8"), - "", - "the held close tail must be emitted after collection" - ); - assert!( - ad_bids_state - .lock() - .expect("should lock bid state") - .is_some(), - "collection must run when the held tail is emitted" - ); - } - - #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); - - let ready = hold.push(b"painted"); - let held = hold.finish(); - - assert_eq!( - std::str::from_utf8(&ready).expect("should be utf8"), - "painted", - "content before should stream before auction collection" - ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "the close-body tag and trailing bytes should be held" - ); - } - - #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); - - let first = hold.push(b"painted"); - let held = hold.finish(); - - let streamed = [first, second].concat(); - assert_eq!( - std::str::from_utf8(&streamed).expect("should be utf8"), - "painted", - "split bytes must not leak before auction collection" - ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "split close-body tag should be held intact" - ); - } - #[test] fn unsupported_encoding_response_is_returned_unmodified() { assert_eq!( @@ -9335,25 +8106,14 @@ mod tests { #[cfg(test)] mod creative_opportunities_tests { - use super::super::{ - MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, - build_bids_script, build_bids_script_performance_mark, html_escape_for_script, - }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderSourceV1, MediaType}; + use super::super::{MatchedSlotsContext, build_auction_request, build_browser_slots_v1}; + use crate::auction::types::MediaType; use crate::consent::ConsentContext; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, }; use crate::http_util::RequestInfo; use crate::price_bucket::PriceGranularity; - use crate::settings::Settings; - use std::collections::HashMap; - - // Rewriting is enabled by default; tests disable it when they need to - // inspect sanitizer-accepted URLs directly. - fn test_settings() -> Settings { - Settings::default() - } fn make_config() -> CreativeOpportunitiesConfig { CreativeOpportunitiesConfig { @@ -9387,101 +8147,8 @@ mod tests { } } - fn make_bid( - slot_id: &str, - price: f64, - bidder: &str, - ad_id: &str, - nurl: &str, - burl: &str, - ) -> Bid { - Bid { - slot_id: slot_id.to_string(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - price: Some(price), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: bidder.to_string(), - width: 300, - height: 250, - nurl: Some(nurl.to_string()), - burl: Some(burl.to_string()), - bid_id: None, - ad_id: Some(ad_id.to_string()), - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - } - } - - #[test] - fn ad_slots_script_contains_slot_data() { - let slots = vec![make_slot()]; - let config = make_config(); - let script = build_ad_slots_script(&slots, &config, "/"); - assert!( - script.contains("window.tsjs=window.tsjs||{}"), - "should initialise tsjs namespace" - ); - assert!( - script.contains(".adSlots=JSON.parse"), - "should use JSON.parse for adSlots" - ); - assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - assert!(!script.contains("adInit"), "must NOT contain adInit"); - assert!( - !script.contains("__ts_request_id"), - "must NOT contain request_id" - ); - } - - #[test] - fn ad_slots_script_is_xss_safe() { - let slots = vec![make_slot()]; - let config = make_config(); - let script = build_ad_slots_script(&slots, &config, "/"); - let inner = script - .trim_start_matches(""); - assert!(!inner.contains('<'), "no unescaped < in script content"); - assert!(!inner.contains('>'), "no unescaped > in script content"); - } - #[test] - fn ad_slots_script_omits_only_over_limit_dynamic_slot() { - let mut over_limit = make_slot(); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("template should compile"); - let mut valid_static = make_slot(); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - let slots = vec![over_limit, valid_static]; - let config = make_config(); - let request_path = format!("/{}", "a".repeat(60)); - - let script = build_ad_slots_script(&slots, &config, &request_path); - - assert!( - !script.contains("over_limit_dynamic"), - "should omit the over-limit dynamic slot" - ); - assert!( - script.contains("valid_static_sibling"), - "should retain the valid static sibling" - ); - } - - #[test] - fn build_slot_json_renders_section_from_request_path() { + fn browser_slots_render_section_from_request_path() { let mut config = make_config(); config.gam_network_id = "99999".to_string(); config.section_root = Some("homepage".to_string()); @@ -9490,25 +8157,22 @@ mod tests { slot.compile_unit_template() .expect("template should compile"); - let news_section = config.section_for_path("/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); + let news = + build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/news/article-123"); assert_eq!( - news["gam_unit_path"], "/99999/example/news", + news[0].gam_unit_path, "/99999/example/news", "section should derive from the first path segment" ); - let home_section = config.section_for_path("/"); - let home = crate::publisher::build_slot_json(&slot, &config, &home_section) - .expect("should render slot"); + let home = build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/"); assert_eq!( - home["gam_unit_path"], "/99999/example/homepage", + home[0].gam_unit_path, "/99999/example/homepage", "root path should use section_root" ); } #[test] - fn build_slot_json_honours_configured_section_segment() { + fn browser_slots_honour_configured_section_segment() { // Locale-prefixed publisher: `/en/news/article` must resolve to the // `news` unit, not `en`. let mut config = make_config(); @@ -9520,1239 +8184,55 @@ mod tests { slot.compile_unit_template() .expect("template should compile"); - let news_section = config.section_for_path("/en/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); + let news = build_browser_slots_v1( + std::slice::from_ref(&slot), + &config, + "/en/news/article-123", + ); assert_eq!( - news["gam_unit_path"], "/99999/example/news", + news[0].gam_unit_path, "/99999/example/news", "section should derive from the configured segment index" ); - let locale_root_section = config.section_for_path("/en"); - let locale_root = - crate::publisher::build_slot_json(&slot, &config, &locale_root_section) - .expect("should render slot"); + let locale_root = build_browser_slots_v1(std::slice::from_ref(&slot), &config, "/en"); assert_eq!( - locale_root["gam_unit_path"], "/99999/example/homepage", + locale_root[0].gam_unit_path, "/99999/example/homepage", "a path with no segment at the configured index should use section_root" ); } #[test] - fn bid_map_includes_nurl_and_burl() { - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ), - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); - let obj = entry.as_object().expect("should be object"); - assert_eq!( - obj.get("hb_pb").and_then(|v| v.as_str()), - Some("1.50"), - "should bucket price with dense granularity" - ); - assert_eq!( - obj.get("hb_bidder").and_then(|v| v.as_str()), - Some("kargo"), - "should include bidder" - ); - assert_eq!( - obj.get("hb_adid").and_then(|v| v.as_str()), - Some("abc123"), - "should fall back to ad_id when no cache_id present" + fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path_and_query: "/2024/01/my-article/?edition=fictional", + }; + let request_info = RequestInfo { + host: "publisher.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + "publisher.example.com", + Some("Mozilla/5.0"), ); - assert_eq!( - obj.get("nurl").and_then(|v| v.as_str()), - Some("https://ssp/win"), - "should include nurl" + + assert_eq!(request.user.id, None, "should not forward an EC user id"); + assert!( + request.id.starts_with("ts-req-"), + "should use a non-EC request id, got {}", + request.id ); assert_eq!( - obj.get("burl").and_then(|v| v.as_str()), - Some("https://ssp/bill"), - "should include burl" - ); - } - - #[test] - fn bid_map_exposes_aps_renderer_and_selected_bid_id_without_debug_adm() { - let mut bid = make_bid("atf_sidebar_ad", 1.50, "aps", "fallback-ad", "", ""); - bid.bid_id = Some("selected-bid".to_string()); - bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "selected-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); - bid.nurl = None; - bid.burl = None; - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map["atf_sidebar_ad"] - .as_object() - .expect("should include APS bid"); - - assert_eq!(obj["hb_bidder"], "aps"); - assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); - assert_eq!(obj["renderer"]["bidId"], "selected-bid"); - assert!(obj.get("adm").is_none()); - assert!(obj.get("nurl").is_none()); - assert!(obj.get("burl").is_none()); - assert!(obj.get("metadata").is_none()); - - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } - - #[test] - fn bid_map_omits_zero_creative_dimensions() { - // Missing OpenRTB w/h parse to 0. Emitting w:0/h:0 would make the - // bridge (which nullish-coalesces) size the frame to 0 instead of - // falling back to the slot format, so a zero dimension must be omitted. - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.width = 0; - bid.height = 0; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert!(obj.get("w").is_none(), "should omit zero width"); - assert!(obj.get("h").is_none(), "should omit zero height"); - } - - #[test] - fn bid_map_includes_winning_creative_dimensions() { - // The bridge sizes the inline render from these dimensions; without - // them it falls back to the first configured slot format, which - // mis-sizes a multi-size slot whose winner is not the first format. - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.width = 300; - bid.height = 600; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert_eq!( - obj.get("w").and_then(serde_json::Value::as_u64), - Some(300), - "should include winning creative width" - ); - assert_eq!( - obj.get("h").and_then(serde_json::Value::as_u64), - Some(600), - "should include winning creative height" - ); - } - - #[test] - fn client_bid_map_includes_adm_and_omits_debug_bid_by_default() { - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some("
Creative
".to_string()); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - // Production path (include_debug_bid = false): the creative is always - // included so the bridge can render it locally, but the verbose - // debug_bid blob is not. - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - - assert_eq!( - obj.get("adm").and_then(|v| v.as_str()), - Some("
Creative
"), - "should include creative markup for local rendering by default" - ); - assert!( - obj.get("debug_bid").is_none(), - "should omit the debug_bid blob when debug injection is disabled" - ); - } - - #[test] - fn build_bid_map_sanitizes_hostile_adm() { - // The inline-adm path must run the same opt-in creative-processing - // boundary as the `/auction` path (sanitize → rewrite) before the - // creative reaches window.tsjs.bids, so with sanitization enabled - // hostile executable markup never lands in the client-facing `adm` - // for the Prebid Universal Creative to run. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some( - "
\ - x
" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a sanitized adm"); - - assert!( - !adm.contains(" elements from the inline adm" - ); - assert!( - !adm.contains("alert(1)"), - "should strip inline script bodies from the inline adm" - ); - assert!( - !adm.contains("onclick"), - "should strip on* event-handler attributes from the inline adm" - ); - assert!( - !adm.contains("javascript:"), - "should strip javascript: URIs from the inline adm" - ); - } - - #[test] - fn build_bid_map_can_skip_rewriting_while_sanitizing() { - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - settings.auction.rewrite_creatives = false; - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some( - "
\ - x\ -
" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &settings, - "https://publisher.example", - false, - ); - let adm = map - .get("atf_sidebar_ad") - .and_then(|value| value.as_object()) - .and_then(|object| object.get("adm")) - .and_then(|value| value.as_str()) - .expect("should include a sanitized adm"); - - assert!( - adm.contains(r#"href="https://click.example/landing""#), - "should keep accepted click URLs direct: {adm}" - ); - assert!( - adm.contains(r#"src="https://cdn.example/ad.png""#), - "should keep accepted resource URLs direct: {adm}" - ); - assert!( - !adm.contains("/first-party/"), - "should skip first-party URL rewriting: {adm}" - ); - assert!( - !adm.contains("data-tsclick"), - "should skip click-guard attributes: {adm}" - ); - assert!( - !adm.contains("marker") && !adm.contains("onclick"), - "should still sanitize executable markup: {adm}" - ); - } - - #[test] - fn build_bid_map_omits_oversized_adm() { - // Creatives larger than the 1 MiB cap are rejected (empty result) - // in every processing mode, so the inline `adm` is omitted rather - // than shipping an unbounded creative to the client. Runs with - // default settings to cover the shipped configuration. - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - assert!( - obj.get("adm").is_none(), - "should omit the inline adm when the creative exceeds the 1 MiB cap" - ); - } - - #[test] - fn build_bid_map_omits_oversized_adm_when_sanitizing() { - // Creatives larger than the sanitize pass's 1 MiB cap are rejected - // (empty result), so the inline `adm` is omitted and the pbRender - // bridge falls back to the PBS Cache coordinates instead of shipping - // an unbounded creative to the client. - let settings = test_settings(); - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "kargo", - "abc123", - "https://ssp/win", - "https://ssp/bill", - ); - bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - assert!( - obj.get("adm").is_none(), - "should omit the inline adm when the creative exceeds the 1 MiB cap" - ); - } - - // A supplied creative that processing rejects must not fall back to the - // PBS Cache coordinates: the GPT bridge fetches the cached bid's ORIGINAL - // adm, which would undo sanitization and the size cap entirely. - fn cached_bid_with_creative(creative: &str) -> Bid { - Bid { - slot_id: "atf_sidebar_ad".to_string(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - price: Some(1.50), - currency: "USD".to_string(), - creative: Some(creative.to_string()), - adomain: None, - bidder: "prebid".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - ad_id: Some("bid-impression-id".to_string()), - cache_id: Some("cache-uuid".to_string()), - cache_host: Some("prebid-cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - bid_id: None, - creative_id: None, - renderer: None, - metadata: Default::default(), - } - } - - fn assert_no_render_source(settings: &Settings, creative: String, case: &str) { - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = Some(creative); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, settings, "", false); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - - assert!( - obj.get("adm").is_none(), - "{case}: rejected creative should not emit adm" - ); - assert!( - obj.get("hb_cache_host").is_none(), - "{case}: rejected creative should suppress hb_cache_host" - ); - assert!( - obj.get("hb_cache_path").is_none(), - "{case}: rejected creative should suppress hb_cache_path" - ); - } - - #[test] - fn build_bid_map_suppresses_cache_fallback_for_rejected_creatives() { - let mut sanitizing = test_settings(); - sanitizing.auction.sanitize_creatives = true; - - // Script-only creative: sanitization strips everything. - assert_no_render_source( - &sanitizing, - "".to_string(), - "script-only", - ); - // Oversized creative: rejected by the cap in every mode. - assert_no_render_source( - &test_settings(), - format!("
{}
", "a".repeat(1024 * 1024 + 1)), - "oversized", - ); - // An explicit empty `adm` is a supplied creative, not an absent one: - // classifying it as absent would re-enable the raw cache fallback. - assert_no_render_source(&test_settings(), String::new(), "explicit-empty"); - } - - #[test] - fn build_bid_map_keeps_cache_fallback_for_absent_creatives() { - // A bid with no supplied creative is the legitimate PBS Cache case: - // the coordinates are the only render source. - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = None; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have a bid entry"); - - assert_eq!( - obj.get("hb_cache_host").and_then(|v| v.as_str()), - Some("prebid-cache.example.com"), - "absent creative should keep hb_cache_host" - ); - assert_eq!( - obj.get("hb_cache_path").and_then(|v| v.as_str()), - Some("/cache"), - "absent creative should keep hb_cache_path" - ); - } - - #[test] - fn build_bid_map_rewrites_inline_adm_to_absolute_first_party_urls() { - // The inline `adm` is rendered by the Prebid Universal Creative inside - // GAM's iframe (`f.srcdoc = d.ad`), a foreign origin. Proxied URLs must - // therefore be emitted **absolute** against the publisher domain — a - // root-relative `/first-party/proxy` would resolve against GAM and 404. - // The tsjs bundle must NOT be injected into that foreign-origin iframe. - let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; - settings.publisher.domain = "example.com".to_string(); - settings.auction.rewrite_creatives = true; - - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win", - "https://ssp.example.com/bill", - ); - bid.creative = Some( - "" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a rewritten adm"); - - assert!( - adm.contains("https://example.com/first-party/proxy?tsurl="), - "should emit an absolute first-party proxy URL for the foreign-origin render context, got: {adm}" - ); - assert!( - !adm.contains("src=\"/first-party/proxy"), - "should not emit a root-relative proxy URL that 404s under GAM's origin, got: {adm}" - ); - assert!( - !adm.contains("https://cdn.example.com/pixel.png"), - "should proxy the original absolute CDN URL, got: {adm}" - ); - assert!( - !adm.contains("/static/tsjs="), - "should not inject the tsjs bundle into a foreign-origin creative iframe, got: {adm}" - ); - } - - #[test] - fn build_bid_map_uses_request_origin_for_inline_urls() { - // The inline adm's absolute first-party URLs must resolve against the - // origin the visitor is on (here an HTTP dev host with a port), not the - // configured publisher domain. - let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; - settings.publisher.domain = "example.com".to_string(); - settings.auction.rewrite_creatives = true; - - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win", - "https://ssp.example.com/bill", - ); - bid.creative = Some( - "" - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &settings, - "http://localhost:7676", - false, - ); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a rewritten adm"); - - assert!( - adm.contains("http://localhost:7676/first-party/proxy?tsurl="), - "should emit URLs against the request origin, got: {adm}" - ); - assert!( - !adm.contains("https://example.com/first-party/proxy"), - "must not fall back to the configured publisher domain, got: {adm}" - ); - } - - #[test] - fn build_bid_map_expands_auction_price_macro_before_rewrite() { - // ${AUCTION_PRICE} must be resolved to the clearing price before the - // creative is rewritten and signed. Otherwise URL rewriting encodes the - // literal macro (`%24%7BAUCTION_PRICE%7D`) into the signed proxy/click - // URL, so trackers receive an encoded macro instead of the price and the - // signature locks the wrong value. - let mut settings = test_settings(); - settings.publisher.domain = "example.com".to_string(); - settings.auction.rewrite_creatives = true; - - let mut winning_bids = HashMap::new(); - let mut bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win", - "https://ssp.example.com/bill", - ); - bid.creative = Some( - "\ - go\ - " - .to_string(), - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let adm = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("adm")) - .and_then(|v| v.as_str()) - .expect("should include a rewritten adm"); - - assert!( - !adm.to_uppercase().contains("AUCTION_PRICE"), - "no literal or encoded ${{AUCTION_PRICE}} macro should survive: {adm}" - ); - assert!( - adm.contains("p=1.5"), - "the exact winning CPM should be substituted into the signed URL: {adm}" - ); - } - - #[test] - fn build_bid_map_expands_auction_price_macro_in_notification_urls() { - // Per OpenRTB the win/billing notices are the primary carriers of - // ${AUCTION_PRICE}, and the bridge fires them verbatim. An unexpanded - // macro would report an unresolved clearing price to the SSP, and - // would disagree with the price already substituted into the adm. - let mut winning_bids = HashMap::new(); - let bid = make_bid( - "atf_sidebar_ad", - 1.50, - "examplessp", - "abc123", - "https://ssp.example.com/win?p=${AUCTION_PRICE}", - "https://ssp.example.com/bill?p=${AUCTION_PRICE}", - ); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .and_then(|v| v.as_object()) - .expect("should have bid entry"); - - for field in ["nurl", "burl"] { - let url = obj - .get(field) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| panic!("should include {field}")); - assert!( - !url.to_uppercase().contains("AUCTION_PRICE"), - "no literal or encoded ${{AUCTION_PRICE}} macro should survive in {field}: {url}" - ); - assert!( - url.ends_with("?p=1.5"), - "the exact winning CPM should be substituted into {field}: {url}" - ); - } - } - - #[test] - fn build_bids_script_escapes_line_separators_in_adm() { - // U+2028/U+2029 are valid JSON string content but terminate inline - // ".to_string(), - width: 300, - height: 250, - })); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map["atf_sidebar_ad"] - .as_object() - .expect("should include APS bid"); - - assert_eq!(obj["hb_bidder"], "aps"); - assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); - assert_eq!(obj["renderer"]["bidId"], "selected-bid"); - assert!(obj.get("adm").is_none()); - - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } - - #[test] - fn bid_map_falls_back_to_bid_id_when_cache_id_and_ad_id_absent() { - // Real shape for bidders that return neither a Prebid Cache UUID nor - // `adid` in the OpenRTB response, but always carry `id` (the bid's own - // identifier) per spec. Without this fallback the bid reaches the page - // with no hb_adid, so no targeting key is set and the render bridge - // never receives a matching `Prebid Request`. - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - price: Some(1.00), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "example-bidder".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: Some("019f7e2a-b45b-70b0-a2d1-b651c430700b".to_string()), - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert_eq!( - obj.get("hb_adid").and_then(|v| v.as_str()), - Some("019f7e2a-b45b-70b0-a2d1-b651c430700b"), - "should fall back to bid_id when cache_id and ad_id are both absent" - ); - } - - #[test] - fn bid_map_omits_hb_adid_when_cache_id_ad_id_and_bid_id_all_absent() { - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - Bid { - slot_id: "atf_sidebar_ad".to_string(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - price: Some(0.50), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "ordinary".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); - assert!( - obj.get("hb_adid").is_none(), - "should omit hb_adid when no cache_id, ad_id, or bid_id" - ); - } - - #[test] - fn bid_map_excludes_slot_when_price_is_none() { - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "no-price-slot".to_string(), - Bid { - slot_id: "no-price-slot".to_string(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - price: None, - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: "kargo".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - ad_id: None, - creative_id: None, - renderer: None, - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }, - ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - assert!( - map.is_empty(), - "slot with no price should be excluded from bid map" - ); - } - - #[test] - fn bids_script_is_xss_safe() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - let script = build_bids_script(&map); - let inner = script - .trim_start_matches(""); - assert!(!inner.contains('<'), "no unescaped < in bids script"); - assert!(!inner.contains('>'), "no unescaped > in bids script"); - } - - #[test] - fn bids_script_performance_mark_is_exact_and_not_yet_wired() { - let fragment = build_bids_script_performance_mark(); - assert_eq!( - fragment, - "(function(){try{window.performance.mark(\"tsjs:bids-script\");}catch(_){}})();" - ); - assert!(!fragment.contains("__tsjsPerf")); - assert!( - !build_bids_script(&serde_json::Map::new()).contains("tsjs:bids-script"), - "Task 19 owns the coordinated production insertion" - ); - } - - #[test] - fn bids_script_schedules_ad_init_without_retry_timer() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - assert!( - script.contains("t.scheduleInitialAdInit"), - "should hand off bids to the deferred adInit scheduler" - ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); - assert!( - !script.contains("prevGptSlots"), - "should not use TS-owned slots as adInit success signal" - ); - } - - #[test] - fn bids_script_defers_ad_init_until_after_hydration() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - // adInit() mutates ad-slot subtrees (GPT defineSlot on the - // `-container` wrapper). Running it synchronously at body-parse time - // lands those mutations inside React's hydration window and trips a - // #418 hydration mismatch. The deferral lifecycle (window `load`, - // double `requestAnimationFrame`, generation-0 pinning via - // `tsjs.navGeneration`) lives in the GPT bundle module (with a - // head-injected fallback in gpt_bootstrap.js) where it is executable - // under Vitest (schedule_initial_ad_init.test.ts); this inline - // script must only delegate to that scheduler. - assert!( - script.contains("var s=t.scheduleInitialAdInit"), - "should delegate deferral to the installed scheduler" - ); - // The bids payload is handed to the scheduler (which applies it only - // while the page is still on navigation generation 0) instead of - // being assigned unconditionally, so a faster SPA navigation's live - // bids cannot be clobbered by the stale SSR payload. - assert!( - script.contains("if(typeof s===\"function\")s(b)"), - "should pass the SSR bids payload to the scheduler" - ); - assert!( - script.contains("else t.bids=b"), - "should fall back to a plain bids assignment without a scheduler" - ); - assert!( - !script.contains(".bids=JSON.parse"), - "should not assign the SSR payload unconditionally" - ); - // The one hydration-unsafe thing this script could do is invoke - // adInit synchronously at body-parse time — it must not. - assert!( - !script.contains("adInit()"), - "should not invoke adInit synchronously at parse time" - ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); - } - - #[test] - fn auction_request_without_ec_id_omits_user_id_and_uses_non_ec_request_id() { - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path_and_query: "/2024/01/my-article/?edition=fictional", - }; - let request_info = RequestInfo { - host: "publisher.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "publisher.example.com", - Some("Mozilla/5.0"), - ); - - assert_eq!(request.user.id, None, "should not forward an EC user id"); - assert!( - request.id.starts_with("ts-req-"), - "should use a non-EC request id, got {}", - request.id - ); - assert_eq!( - request.publisher.page_url.as_deref(), - Some("https://publisher.example.com/2024/01/my-article/"), - "should preserve the page path but strip client query data for auction providers" + request.publisher.page_url.as_deref(), + Some("https://publisher.example.com/2024/01/my-article/"), + "should preserve the page path but strip client query data for auction providers" ); } @@ -10834,50 +8314,6 @@ mod tests { "should preserve existing EC-derived request id when present" ); } - - #[test] - fn html_escape_encodes_special_chars() { - assert_eq!( - html_escape_for_script("text\\with\\backslash"), - "text\\\\with\\\\backslash", - "should escape backslashes" - ); - assert_eq!( - html_escape_for_script("string\"with\"quotes"), - "string\\\"with\\\"quotes", - "should escape quotes" - ); - assert_eq!( - html_escape_for_script("simple"), - "simple", - "should not change simple text" - ); - assert_eq!( - html_escape_for_script("both\\\"mixed"), - "both\\\\\\\"mixed", - "should escape both backslashes and quotes" - ); - assert_eq!( - html_escape_for_script(""), - "\\u003Cscript\\u003Ealert(1)\\u003C/script\\u003E", - "should unicode-escape angle brackets to prevent script injection" - ); - assert_eq!( - html_escape_for_script("a&b"), - "a\\u0026b", - "should unicode-escape ampersand" - ); - assert_eq!( - html_escape_for_script("line\u{2028}sep"), - "line\\u2028sep", - "should unicode-escape U+2028 line separator" - ); - assert_eq!( - html_escape_for_script("para\u{2029}sep"), - "para\\u2029sep", - "should unicode-escape U+2029 paragraph separator" - ); - } } mod page_bids_no_match_tests { @@ -10972,15 +8408,11 @@ mod tests { } fn make_page_bids_request(path: &str) -> Request { - make_page_bids_request_on(PAGE_BIDS_PATH, path) - } - - /// Builds a page-bids request against an explicit endpoint path, so the - /// canonical route and its deprecated alias can be compared directly. - fn make_page_bids_request_on(endpoint: &str, path: &str) -> Request { let mut req = Request::builder() .method(Method::GET) - .uri(format!("https://test-publisher.com{endpoint}?path={path}")) + .uri(format!( + "https://test-publisher.com{PAGE_BIDS_PATH}?path={path}" + )) .body(EdgeBody::empty()) .expect("should build test request"); // Pass the same-origin gate the way a browser fetch from the @@ -11305,10 +8737,9 @@ mod tests { async fn disabled_auction_returns_exact_failed_decisions() { // [auction].enabled = false is a global kill switch: it must disable // the entire server-side ad stack, not just SSP calls. Returning slot - // definitions would let the SPA hook assign `ts.adSlots` and call - // `adInit()`, creating/refreshing GPT slots client-side even though - // the auction is off. Consent is allowed here so the test isolates - // the kill switch. + // definitions would let the hard-cutover browser runtime create or + // refresh GPT slots even though the auction is off. Consent is + // allowed here so the test isolates the kill switch. let settings = settings_with_co_auction_disabled(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let slots = article_slot(); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts index 914a50301..c9ec1d4e2 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts @@ -49,6 +49,19 @@ function descriptor() { } test.describe("APS renderer v1 protocol", () => { + test("leaves every removed or unknown APS route unserved", async ({ + page, + }) => { + for (const path of [ + "/integrations/aps/renderer", + "/integrations/aps/renderer/v2", + "/integrations/aps/runner/v1.js", + ]) { + const response = await page.request.get(runtimeUrl(path)); + expect(response.status(), path).toBe(404); + } + }); + test("uses one port, reports ordered progress, and fails closed", async ({ page, }) => { diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts index f88f57f55..cc2c0b1c3 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts @@ -20,11 +20,7 @@ type HeapCheckpoint = | "afterSpaNavigation"; interface PerfApi { - addAdUnits(unit: { - code: string; - mediaTypes: { banner: { sizes: Array<[number, number]> } }; - }): void; - renderAdUnit(code: string): void; + requestAds(options?: { slots?: readonly string[] }): Promise; } function fixtureDocument(): string { @@ -33,10 +29,10 @@ function fixtureDocument(): string { TSJS deterministic performance fixture v1
', - renderer, - price: 1.23, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'fictional-creative-id', - adomain: ['advertiser.example'], - }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, [ - { adUnitCode: 'div-aps', bidId: 'prebid-request-id' }, - ]); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual( - expect.objectContaining({ - requestId: 'prebid-request-id', - bidderCode: 'aps', - ad: '', - trustedServerRenderer: renderer, - meta: { - advertiserDomains: ['advertiser.example'], - trustedServerRenderer: renderer, - }, - }) - ); - }); - - it('drops an APS bid whose renderer fails admission validation', () => { - const result = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - adm: '', - renderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - price: 1.23, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'fictional-creative-id', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'prebid-request-id' }] - ); - - expect(result).toEqual([]); - }); - - it('falls back to impid when no matching bidRequest found', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'div-gpt-2', - adm: '
Ad2
', - price: 2.0, - width: 728, - height: 90, - seat: 'rubicon', - creativeId: 'cr-456', - adomain: [], - }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, []); - - expect(result).toHaveLength(1); - expect(result[0]!.requestId).toBe('div-gpt-2'); - expect(result[0]!.cpm).toBe(2.0); - }); - - it('handles multiple bids across different impids', () => { - const auctionBids: AuctionBid[] = [ - { - impid: 'slot-a', - adm: '
A
', - price: 1.0, - width: 300, - height: 250, - seat: 'bidderA', - creativeId: 'cr-a', - adomain: [], - }, - { - impid: 'slot-b', - adm: '
B
', - price: 2.0, - width: 728, - height: 90, - seat: 'bidderB', - creativeId: 'cr-b', - adomain: ['b.com'], - }, - ]; - const bidRequests = [ - { adUnitCode: 'slot-a', bidId: 'req-a' }, - { adUnitCode: 'slot-b', bidId: 'req-b' }, - ]; - - const result = auctionBidsToPrebidBids(auctionBids, bidRequests); - - expect(result).toHaveLength(2); - expect(result[0]!.requestId).toBe('req-a'); - expect(result[1]!.requestId).toBe('req-b'); - }); -}); - -describe('prebid/installPrebidNpm', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset requestBids to the mock so each test starts fresh - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - mockGetConfig.mockReset(); - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete testWindow.__tsjs_prebid; - delete testWindow.__tsjs_prebid_diagnostics; - delete testWindow.tsjs; - delete (mockPbjs as unknown as Record).__tsApsBidResponseListenerInstalled; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('registers the trustedServer bid adapter', () => { - installPrebidNpm(); - - expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); - expect(mockRegisterBidAdapter).toHaveBeenCalledWith( - undefined, - 'trustedServer', - expect.objectContaining({ - code: 'trustedServer', - supportedMediaTypes: ['banner'], - isBidRequestValid: expect.any(Function), - buildRequests: expect.any(Function), - interpretResponse: expect.any(Function), - }) - ); - }); - - it('registers accepted APS descriptors under Prebid generated ad IDs', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'prebid-generated-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - trustedServerRenderer: renderer, - }); - - const entry = apsPrebidRenderers()['prebid-generated-ad-id']!; - expect(entry).toEqual( - expect.objectContaining({ - adUnitCode: 'div-aps', - renderer, - expiresAt: expect.any(Number), - markRendered: expect.any(Function), - markWinner: expect.any(Function), - }) - ); - - entry.markWinner(); - entry.markRendered(); - // markWinner routes through the public markWinningBidAsUsed API, which - // marks the bid as both winning and rendered in one call. - expect(mockMarkWinningBidAsUsed).toHaveBeenCalledWith({ - adId: 'prebid-generated-ad-id', - events: true, - }); - }); - - it('registers APS renderer via requestId when Prebid strips the custom field', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const [built] = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - renderer, - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'cr-aps', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-strip' }] - ); - - // Prebid delivered the bid with the custom top-level field REMOVED — only - // first-class fields (requestId, meta) survive normalization. - const delivered: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'stripped-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: built.requestId, - meta: built.meta, - }; - bidResponseListener!(delivered); - - const entry = apsPrebidRenderers()['stripped-field-ad-id']; - expect(entry).toEqual( - expect.objectContaining({ adUnitCode: 'div-aps', renderer, markWinner: expect.any(Function) }) - ); - // The capability is scrubbed from the delivered bid after registration. - expect(delivered.meta).not.toHaveProperty('trustedServerRenderer'); - }); - - it('registers a distinct renderer for each of multiple APS bids on one imp', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // Two APS bids for the same imp share a requestId; each built bid must carry - // its own descriptor so neither registration is lost. - const firstRenderer = { ...apsRenderer(), creativeId: 'cr-aps-first' }; - const secondRenderer = { ...apsRenderer(), creativeId: 'cr-aps-second' }; - const sharedBid = { - impid: 'div-aps', - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - adomain: [], - }; - const built = auctionBidsToPrebidBids( - [ - { ...sharedBid, renderer: firstRenderer, creativeId: 'cr-aps-first' }, - { ...sharedBid, renderer: secondRenderer, creativeId: 'cr-aps-second' }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-shared' }] - ); - expect(built).toHaveLength(2); - - for (const [index, bid] of built.entries()) { - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: `shared-imp-ad-id-${index}`, - adUnitCode: 'div-aps', - ttl: 300, - requestId: bid.requestId, - meta: bid.meta, - }); - } - - const registry = apsPrebidRenderers(); - expect(registry['shared-imp-ad-id-0']).toEqual( - expect.objectContaining({ renderer: firstRenderer }) - ); - expect(registry['shared-imp-ad-id-1']).toEqual( - expect.objectContaining({ renderer: secondRenderer }) - ); - }); - - it('does not register anything for a stripped bid that carries no meta descriptor', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // First bid registers through the surviving custom-field path. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'surviving-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: 'req-reused', - trustedServerRenderer: apsRenderer(), - }); - expect(apsPrebidRenderers()['surviving-field-ad-id']).toBeDefined(); - - // A later field-stripped bid reusing the same requestId has no descriptor of its - // own, so no stale renderer may be registered for it. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'reused-request-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - requestId: 'req-reused', - meta: { advertiserDomains: [] }, - }); - expect(apsPrebidRenderers()['reused-request-ad-id']).toBeUndefined(); - }); - - it('registers and scrubs on bidAccepted before later events can observe the descriptor', () => { - installPrebidNpm(); - - const bidAcceptedListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidAccepted' - )?.[1] as ((bid: Record) => void) | undefined; - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidAcceptedListener).toBeTypeOf('function'); - expect(bidResponseListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const [built] = auctionBidsToPrebidBids( - [ - { - impid: 'div-aps', - renderer, - price: 1.0, - width: 300, - height: 250, - seat: 'aps', - creativeId: 'cr-aps', - adomain: [], - }, - ], - [{ adUnitCode: 'div-aps', bidId: 'req-accepted' }] - ); - - // Prebid emits bidAccepted and bidResponse with the same in-place-mutated - // bid object; the bidAccepted pass must register and scrub both carriers. - const accepted: Record = { - ...built, - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'accepted-ad-id', - adUnitCode: 'div-aps', - }; - bidAcceptedListener!(accepted); - - expect(apsPrebidRenderers()['accepted-ad-id']).toEqual( - expect.objectContaining({ adUnitCode: 'div-aps', renderer }) - ); - expect(accepted).not.toHaveProperty('trustedServerRenderer'); - expect(accepted.meta).not.toHaveProperty('trustedServerRenderer'); - - // The later bidResponse pass sees the already-scrubbed object and no-ops. - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - bidResponseListener!(accepted); - expect(apsPrebidRenderers()['accepted-ad-id']).toEqual(expect.objectContaining({ renderer })); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it('tolerates a non-object meta value on the bid', () => { - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidResponseListener).toBeTypeOf('function'); - - // A module overwrote meta with a string and there is no top-level field: - // nothing registers and nothing throws. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'corrupt-meta-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: 'corrupted', - }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['corrupt-meta-ad-id']).toBeUndefined(); - - // With a surviving top-level field the corrupt meta must not block registration. - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'corrupt-meta-with-field-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: 'corrupted', - trustedServerRenderer: apsRenderer(), - }); - expect(apsPrebidRenderers()['corrupt-meta-with-field-ad-id']).toBeDefined(); - }); - - it('does not register malformed or non-trusted APS renderer capabilities', () => { - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - installPrebidNpm(); - - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const malformedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'malformed-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - trustedServerRenderer: { ...apsRenderer(), aaxResponse: 'invalid' }, - }; - bidResponseListener!(malformedBid); - bidResponseListener!({ - adapterCode: 'publisherAdapter', - bidderCode: 'aps', - adId: 'foreign-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: apsRenderer(), - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['malformed-ad-id']).toBeUndefined(); - expect(testWindow.tsjs?.apsPrebidRenderers?.['foreign-ad-id']).toBeUndefined(); - expect(malformedBid).not.toHaveProperty('trustedServerRenderer'); - expect(warnSpy).toHaveBeenCalledWith( - '[tsjs-prebid] rejected APS renderer capability that failed registration' - ); - }); - - it('calls setConfig with debug=false by default', () => { - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ debug: false })); - }); - - it('respects custom config values', () => { - installPrebidNpm({ - endpoint: '/custom/auction', - timeout: 2000, - debug: true, - }); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 2000 }) - ); - }); - - it('calls processQueue after configuration', () => { - installPrebidNpm(); - expect(mockProcessQueue).toHaveBeenCalledTimes(1); - }); - - it('reports the User ID modules selected by the generated bundle', () => { - installPrebidNpm(); - - expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ - includedModules: ['sharedIdSystem'], - configuredUserIdNames: [], - missingConfiguredUserIdNames: [], - }); - }); - - it('refreshes late User ID config without repeating missing-module warnings', () => { - installPrebidNpm(); - mockGetConfig.mockImplementation((key?: string) => - key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} - ); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - - mockPbjs.requestBids({ adUnits: [] }); - mockPbjs.requestBids({ adUnits: [] }); - - expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ - includedModules: ['sharedIdSystem'], - configuredUserIdNames: ['pairId', 'sharedId'], - missingConfiguredUserIdNames: ['pairId'], - }); - expect( - warnSpy.mock.calls.filter(([message]) => String(message).includes('"pairId"')) - ).toHaveLength(1); - }); - - it('returns the pbjs instance', () => { - const result = installPrebidNpm(); - expect(result).toBe(mockPbjs); - }); - - it('installs only once per page via the __tsjsPrebidShimInstalled sentinel', () => { - const first = installPrebidNpm(); - const wrappedRequestBids = mockPbjs.requestBids; - const second = installPrebidNpm(); - - expect(second).toBe(first); - expect(mockRegisterBidAdapter).toHaveBeenCalledTimes(1); - expect(mockPbjs.requestBids).toBe(wrappedRequestBids); - expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); - }); - - it('warns once about an unstamped User ID manifest instead of once per module', () => { - delete testWindow.__tsjs_prebid_bundle; - mockGetConfig.mockImplementation((key?: string) => - key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} - ); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - - installPrebidNpm(); - mockPbjs.requestBids({ adUnits: [] }); - - expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ - includedModules: [], - configuredUserIdNames: ['pairId', 'sharedId'], - missingConfiguredUserIdNames: [], - }); - const manifestWarnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes('did not stamp a User ID module manifest') - ); - expect(manifestWarnings).toHaveLength(1); - const moduleWarnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes('is not included in the external bundle') - ); - expect(moduleWarnings).toHaveLength(0); - - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - describe('adapter spec', () => { - function getAdapterSpec(): TestAdapterSpec { - installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0]![2] as TestAdapterSpec; - } - - it('isBidRequestValid always returns true', () => { - const spec = getAdapterSpec(); - expect(spec.isBidRequestValid({})).toBe(true); - }); - - it('buildRequests creates a POST request to /auction', () => { - const spec = getAdapterSpec(); - const bidRequests = [ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]; - - const result = spec.buildRequests(bidRequests); - - expect(result.method).toBe('POST'); - expect(result.url).toBe('/auction'); - expect(result.options).toEqual({ contentType: 'application/json' }); - - const payload = JSON.parse(result.data); - expect(payload.adUnits).toHaveLength(1); - expect(payload.adUnits[0].code).toBe('div-gpt-1'); - expect(payload.eids).toBeUndefined(); - }); - - it('buildRequests includes current Prebid EIDs in the /auction payload', () => { - const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); - - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'id5-sync.com', - uids: [{ id: 'ID5_abc', atype: 1 }], - }, - { - source: 'sharedid.org', - uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], - }, - { - source: 'google.com', - uids: [{ id: 'pair_123', atype: 571187 }], - }, - ]); - }); - - it('buildRequests clears stale ts-eids cookie when current Prebid EIDs are absent', () => { - const spec = getAdapterSpec(); - document.cookie = 'ts-eids=stale-value'; - mockGetUserIdsAsEids.mockReturnValue([]); - - spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - expect(document.cookie).toBe(''); - }); - - it('buildRequests preserves uid ext and sanitizes invalid atype values', () => { - const spec = getAdapterSpec(); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - atype: 2_147_483_648, - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - atype: 1.5, - }, - ], - }, - ]); - - const result = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - params: {}, - }, - ]); - - const payload = JSON.parse(result.data); - expect(payload.eids).toEqual([ - { - source: 'adserver.org', - uids: [ - { - id: 'uid-with-ext', - atype: 1, - ext: { provider: 'liveintent.com', rtiPartner: 'TDID' }, - }, - { - id: 'uid-bad-atype', - ext: { keep: true }, - }, - { - id: 'uid-float-atype', - }, - ], - }, - ]); - }); - - it('buildRequests uses custom endpoint when configured', () => { - mockRegisterBidAdapter.mockClear(); - installPrebidNpm({ endpoint: '/custom/auction' }); - const spec = mockRegisterBidAdapter.mock.calls[0]![2]; - - const result = spec.buildRequests([ - { - adUnitCode: 'slot1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - expect(result.url).toBe('/custom/auction'); - }); - - it('interpretResponse parses seatbid and returns Prebid bids', () => { - const spec = getAdapterSpec(); - - const built = spec.buildRequests([ - { - adUnitCode: 'div-gpt-1', - bidId: 'bid-1', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - const serverResponse = { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [ - { - impid: 'div-gpt-1', - price: 4.5, - adm: '
Creative
', - w: 300, - h: 250, - crid: 'cr-789', - adomain: ['advertiser.com'], - }, - ], - }, - ], - }, - }; - - const bids = spec.interpretResponse(serverResponse, built); - - expect(bids).toHaveLength(1); - expect(bids[0]).toEqual( - expect.objectContaining({ - requestId: 'bid-1', - cpm: 4.5, - width: 300, - height: 250, - ad: '
Creative
', - currency: 'USD', - netRevenue: true, - bidderCode: 'appnexus', - }) - ); - }); - - it('interpretResponse handles empty/missing seatbid', () => { - const spec = getAdapterSpec(); - const built = spec.buildRequests([]); - - expect(spec.interpretResponse({ body: {} }, built)).toEqual([]); - expect(spec.interpretResponse({ body: null }, built)).toEqual([]); - expect(spec.interpretResponse({}, built)).toEqual([]); - }); - - it('keeps request mapping isolated across overlapping auctions', () => { - const spec = getAdapterSpec(); - - const requestA = spec.buildRequests([ - { - adUnitCode: 'slot-a', - bidId: 'bid-a', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - const requestB = spec.buildRequests([ - { - adUnitCode: 'slot-b', - bidId: 'bid-b', - bidder: 'trustedServer', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - }, - ]); - - const responseA = { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [{ impid: 'slot-a', price: 1.1, adm: '
A
', w: 300, h: 250 }], - }, - ], - }, - }; - const responseB = { - body: { - seatbid: [ - { - seat: 'rubicon', - bid: [{ impid: 'slot-b', price: 2.2, adm: '
B
', w: 300, h: 250 }], - }, - ], - }, - }; - - const bidsA = spec.interpretResponse(responseA, requestA); - const bidsB = spec.interpretResponse(responseB, requestB); - - expect(bidsA[0]!.requestId).toBe('bid-a'); - expect(bidsB[0]!.requestId).toBe('bid-b'); - }); - }); - - describe('requestBids shim', () => { - it('injects trustedServer bidder into every ad unit', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { bids: [{ bidder: 'appnexus', params: {} }] }, - { bids: [{ bidder: 'rubicon', params: {} }] }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // Each ad unit should have trustedServer added - for (const unit of adUnits) { - const hasTsBidder = unit.bids.some((b: TestBid) => b.bidder === 'trustedServer'); - expect(hasTsBidder).toBe(true); - } - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.bidderParams).toEqual({ appnexus: {} }); - expect(adUnits[0]!.bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - expect(adUnits[1]!.bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - - // Should call through to original requestBids - expect(mockRequestBids).toHaveBeenCalled(); - }); - - it('does not duplicate trustedServer if already present', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ bids: [{ bidder: 'trustedServer', params: {} }] }]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsCount = adUnits[0]!.bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; - expect(tsCount).toBe(1); - }); - - it('captures per-bidder params on trustedServer bid', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - expect(adUnits[0]!.bids.map((b: TestBid) => b.bidder)).toEqual(['trustedServer']); - }); - - it('preserves captured bidder params when requestBids runs twice on the same ad unit', () => { - const pbjs = installPrebidNpm(); - - // First auction: inline server-side params supplied by the publisher. - const adUnits = [ - { - code: 'div-1', - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // Second auction (refresh/re-auction) with the SAME ad unit object: the - // server-side bidder entries were already pruned, so the shim must not - // overwrite the captured params with an empty object. - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('adds bids array to ad units that have none', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ code: 'div-1' }] as TestAdUnit[]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - expect(adUnits[0]!.bids).toHaveLength(1); - expect(adUnits[0]!.bids![0]!.bidder).toBe('trustedServer'); - }); - - it('normalizes a truthy non-array bids value without throwing', () => { - const pbjs = installPrebidNpm(); - const adUnits = [ - { code: 'example-malformed-slot', bids: { malformed: true } }, - ] as unknown as TestAdUnit[]; - - expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); - - expect(adUnits[0]!.bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); - }); - - it('includes zone from mediaTypes.banner.name in trustedServer params', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { name: 'header', sizes: [[728, 90]] } }, - bids: [{ bidder: 'kargo', params: { placementId: '_abc' } }], - }, - { - code: 'ad-fixed_bottom-0', - mediaTypes: { banner: { name: 'fixed_bottom', sizes: [[728, 90]] } }, - bids: [{ bidder: 'kargo', params: { placementId: '_def' } }], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid0 = trustedServerBid(adUnits[0]!); - expect(tsBid0.params.zone).toBe('header'); - - const tsBid1 = trustedServerBid(adUnits[1]!); - expect(tsBid1.params.zone).toBe('fixed_bottom'); - }); - - it('omits zone when mediaTypes.banner.name is not set', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { sizes: [[300, 250]] } }, - bids: [{ bidder: 'appnexus', params: {} }], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.zone).toBeUndefined(); - }); - - it('omits zone when ad unit has no mediaTypes', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.zone).toBeUndefined(); - }); - - it('clears stale zone when existing trustedServer bid is reused', () => { - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - code: 'ad-header-0', - mediaTypes: { banner: { name: 'header', sizes: [[300, 250]] } }, - bids: [ - { bidder: 'trustedServer', params: { custom: 'keep' } }, - { bidder: 'kargo', params: { placementId: '_abc' } }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - let tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.zone).toBe('header'); - expect(tsBid.params.custom).toBe('keep'); - - delete (adUnits[0]!.mediaTypes.banner as { name?: string }).name; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.zone).toBeUndefined(); - expect(tsBid.params.custom).toBe('keep'); - }); - - it('falls back to pbjs.adUnits when requestObj has no adUnits', () => { - const pbjs = installPrebidNpm(); - - mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; - pbjs.requestBids({} as RequestBidsArg); - - const hasTsBidder = (mockPbjs.adUnits[0]!.bids ?? []).some( - (b: TestBid) => b.bidder === 'trustedServer' - ); - expect(hasTsBidder).toBe(true); - }); - - it('syncs a structured ts-eids cookie after bidsBackHandler', () => { - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - const cookieValue = document.cookie.match(/(?:^|; )ts-eids=([^;]+)/)?.[1]; - expect(cookieValue).toBeDefined(); - expect(JSON.parse(atob(cookieValue!))).toEqual([ - { - source: 'sharedid.org', - uids: [ - { id: 'shared_123', atype: 3 }, - { id: 'shared_456', ext: { provider: 'example' } }, - ], - }, - ]); - }); - - it('clears ts-eids cookie after bidsBackHandler when no current EIDs remain', () => { - document.cookie = `ts-eids=${btoa(JSON.stringify([{ source: 'sharedid.org', uids: [{ id: 'stale' }] }]))}`; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - mockGetUserIdsAsEids.mockReturnValue([]); - - const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'appnexus', params: {} }] }], - } as unknown as RequestBidsArg); - - expect(document.cookie).toBe(''); - }); - }); -}); - -describe('prebid/installPrebidNpm with server-injected config', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - document.cookie = 'ts-eids=; Path=/; Max-Age=0'; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('reads timeout and debug from window.__tsjs_prebid', () => { - testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; - - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: true, bidderTimeout: 1500 }) - ); - }); - - it('explicit config overrides server-injected values', () => { - testWindow.__tsjs_prebid = { timeout: 1500, debug: true }; - - installPrebidNpm({ timeout: 3000, debug: false }); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ debug: false, bidderTimeout: 3000 }) - ); - }); - - it('works with no config argument and no injected config', () => { - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining({ debug: false })); - expect(mockProcessQueue).toHaveBeenCalled(); - }); -}); - -describe('prebid/installRefreshHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRequestBids.mockReset(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - testWindow.tsjs = undefined; - delete testWindow.googletag; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - testWindow.tsjs = undefined; - delete testWindow.googletag; - delete testWindow.__tsjs_prebid; - }); - - it('builds refresh ad units from injected slot metadata', () => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [ - [970, 250], - [728, 90], - ], - targeting: { zone: 'homepage', pos: 'atf' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 750, - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - mediaTypes: { - banner: { - name: 'homepage', - sizes: [ - [970, 250], - [728, 90], - ], - }, - }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], - }), - ], - }) - ); - }); - - it('resolves the exact slot when div_ids share a prefix', () => { - // Regression: a single find() with a startsWith() clause returned the - // first slot whose div_id is a prefix of the element id. With div_ids - // "div-ad" and "div-ad-header", refreshing the "div-ad-header" element - // must resolve to the header slot, not the shorter prefix slot. - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'prefix_ad', - gam_unit_path: '/123/prefix', - div_id: 'div-ad', - formats: [[300, 250]], - targeting: { zone: 'prefix' }, - }, - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'div-ad-header', - formats: [[970, 250]], - targeting: { zone: 'header' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-header', - mediaTypes: { - banner: { - name: 'header', - sizes: [[970, 250]], - }, - }, - }), - ], - }) - ); - }); - - it('scopes the GPT targeting call to the refreshed slot code', () => { - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - // Run the bidsBackHandler synchronously so the targeting call fires. - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const originalRefresh = vi.fn(); - // Only the header slot is refreshed; the footer slot must be untouched. - const headerSlot = { - getSlotElementId: vi.fn(() => 'div-ad-header'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn().mockReturnThis(), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [headerSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'header_ad', - gam_unit_path: '/123/header', - div_id: 'div-ad-header', - formats: [[728, 90]], - targeting: { zone: 'header' }, - }, - { - id: 'footer_ad', - gam_unit_path: '/123/footer', - div_id: 'div-ad-footer', - formats: [[728, 90]], - targeting: { zone: 'footer' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh([headerSlot]); - - expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-header']); - expect(originalRefresh).toHaveBeenCalledWith([headerSlot], undefined); - - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it('includes configured client-side bidders in refresh ad units', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - // Original publisher ad unit carries a client-side rubicon bid. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [ - { bidder: 'trustedServer', params: {} }, - { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { bidder: 'trustedServer', params: { zone: 'homepage' } }, - { bidder: 'rubicon', params: { accountId: 1, siteId: 2, zoneId: 3 } }, - ], - }), - ], - }) - ); - - delete testWindow.__tsjs_prebid; - mockPbjs.adUnits = []; - }); - - it('preserves raw server-side bidder params in refresh ad units', () => { - // Original publisher ad unit carries an inline server-side appnexus bid that - // the initial auction has not yet folded into the trustedServer bid. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [{ bidder: 'appnexus', params: { placementId: 12345 } }], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - ], - }), - ], - }) - ); - - mockPbjs.adUnits = []; - }); - - it('recovers params and client-side bids for container-backed slots by injected div_id', () => { - // A TS-owned GPT slot may be defined on `${div_id}-container`, but the - // publisher's Prebid ad unit is keyed by the inner div_id. The synthetic - // refresh code stays the GPT element id (so GPT can match it), while params - // and client-side bids are recovered from the injected div_id candidate. - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - mockPbjs.adUnits = [ - { - code: 'div-ad-x', - bids: [ - { bidder: 'appnexus', params: { placementId: 12345 } }, - { bidder: 'rubicon', params: { accountId: 1 } }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-x-container'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'x_ad', - gam_unit_path: '/123/x', - div_id: 'div-ad-x', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - // Synthetic refresh code stays the GPT element id, not the div_id. - code: 'div-ad-x-container', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - { bidder: 'rubicon', params: { accountId: 1 } }, - ], - }), - ], - }) - ); - - delete testWindow.__tsjs_prebid; - mockPbjs.adUnits = []; - }); - - it('recovers server-side bidder params already folded onto the original trustedServer bid', () => { - // After the initial auction, the requestBids shim has folded the publisher's - // server-side params into the original ad unit's trustedServer bid. A later - // refresh must still recover them by code. - mockPbjs.adUnits = [ - { - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { bidderParams: { appnexus: { placementId: 12345 } } }, - }, - ], - }, - ]; - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [[728, 90]], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - bids: [ - { - bidder: 'trustedServer', - params: { - zone: 'homepage', - bidderParams: { appnexus: { placementId: 12345 } }, - }, - }, - ], - }), - ], - }) - ); - - mockPbjs.adUnits = []; - }); - - it('auctions refreshed TS initial slots and clears stale TS targeting before refresh', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => { - if (key === 'ts_initial') return ['1']; - if (key === 'zone') return ['homepage']; - return []; - }), - getSizes: vi.fn(() => [ - { getWidth: () => 970, getHeight: () => 250 }, - { getWidth: () => 728, getHeight: () => 90 }, - ]), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'homepage_header_ad', - gam_unit_path: '/123/homepage', - div_id: 'div-ad-homepage-header', - formats: [ - [970, 250], - [728, 90], - ], - targeting: { zone: 'homepage' }, - }, - ], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 750, - adUnits: [ - expect.objectContaining({ - code: 'div-ad-homepage-header', - mediaTypes: { - banner: { - name: 'homepage', - sizes: [ - [970, 250], - [728, 90], - ], - }, - }, - bids: [{ bidder: 'trustedServer', params: { zone: 'homepage' } }], - }), - ], - }) - ); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).not.toHaveBeenCalled(); - - const bidsBackHandler = mockRequestBids.mock.calls[0]![0].bidsBackHandler; - bidsBackHandler(); - - expect(setTargetingForGPTAsync).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); - }); - - it('passes an explicitly excluded path directly to GPT after clearing stale targeting', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - const options = { changeCorrelator: false }; - - installRefreshHandler(750); - pubads.refresh([gptSlot], options); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(clearTargeting).toHaveBeenCalledWith('hb_bidder'); - expect(clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_host'); - expect(clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], options); - }); - - it('passes an all-excluded global refresh directly to GPT', () => { - const originalRefresh = vi.fn(); - const trackingSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const measurementSlot = { - getSlotElementId: vi.fn(() => 'div-ad-measurement'), - getAdUnitPath: vi.fn(() => '/123/measurement-only'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const targetSlots = [trackingSlot, measurementSlot]; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => targetSlots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly', '/measurement-only'], - }; - const options = { changeCorrelator: false }; - - installRefreshHandler(750); - pubads.refresh(undefined, options); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(trackingSlot.clearTargeting).toHaveBeenCalled(); - expect(measurementSlot.clearTargeting).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith(undefined, options); - }); - - it('auctions eligible slots and refreshes every slot in a mixed global refresh', () => { - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const originalRefresh = vi.fn(); - const displaySlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => '/123/content'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const trackingSlot = { - getSlotElementId: vi.fn(() => 'div-ad-tracking'), - getAdUnitPath: vi.fn(() => '/123/trackingonly'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const targetSlots = [displaySlot, trackingSlot]; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => targetSlots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh(); - - expect(displaySlot.clearTargeting).toHaveBeenCalled(); - expect(trackingSlot.clearTargeting).toHaveBeenCalled(); - expect(mockRequestBids).toHaveBeenCalledWith( - expect.objectContaining({ - adUnits: [expect.objectContaining({ code: 'div-ad-display' })], - }) - ); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['div-ad-display']); - expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); - - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it.each([ - ['a missing path getter', {}], - ['a non-string path', { getAdUnitPath: vi.fn(() => 123) }], - [ - 'a throwing path getter', - { - getAdUnitPath: vi.fn(() => { - throw new Error('path unavailable'); - }), - }, - ], - ])('fails open to an auction for %s', (_description, pathBehavior) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getTargeting: vi.fn(() => []), - ...pathBehavior, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); - - it.each(['/123/TrackingOnly', '/123/trackingonly/'])( - 'uses literal case-sensitive suffix matching for %s', - (adUnitPath) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => adUnitPath), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { - excludedGamAdUnitPathSuffixes: ['/trackingonly'], - }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - } - ); - - it('passes the adInit internal refresh straight to GPT without a client-side auction', () => { - const originalRefresh = vi.fn(); - const clearTargeting = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - clearTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { adInitRefreshInProgress: true }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([gptSlot], undefined); - }); - - it('runs a client-side auction for publisher refreshes after adInit completes', () => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn(() => []), - clearTargeting: vi.fn(), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { adInitRefreshInProgress: false }; - - installRefreshHandler(750); - pubads.refresh([gptSlot]); - - expect(mockRequestBids).toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - }); -}); - -describe('prebid publisher snapshots and delivery refreshes', () => { - let deliveryAdIds = new WeakMap(); - let installedGptSlots: Array> = []; - let auctionSequence = 0; - - beforeEach(() => { - vi.clearAllMocks(); - deliveryAdIds = new WeakMap(); - installedGptSlots = []; - auctionSequence = 0; - mockRequestBids.mockReset(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.removeAdUnit = mockRemoveAdUnit; - delete (mockPbjs as unknown as Record).__tsRemoveAdUnitWrapped; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - // By default the manifest declares all adapters compiled in. - (window as unknown as { __tsjs_prebid_bundle?: unknown }).__tsjs_prebid_bundle = - DEFAULT_BUNDLE_MANIFEST; - mockPbjs.setTargetingForGPTAsync = undefined; - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - testWindow.tsjs = undefined; - delete testWindow.googletag; - }); - - function installGpt(slots: Array>) { - installedGptSlots = slots; - for (const slot of slots) { - if (!slot || typeof slot !== 'object') continue; - const getTargeting = slot.getTargeting; - const originalGetTargeting = - typeof getTargeting === 'function' - ? (getTargeting as (key: string) => unknown[]).bind(slot) - : undefined; - slot.getTargeting = (key: string) => { - const deliveryAdId = deliveryAdIds.get(slot); - if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; - return originalGetTargeting?.(key) ?? []; - }; - } - - const originalRefresh = vi.fn(); - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => slots), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - installRefreshHandler(640); - return { originalRefresh, pubads }; - } - - function refreshAdUnitFromLastRequest(): Record & { - code?: string; - bids: TestBid[]; - } { - const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; - const unit = lastCall?.[0]?.adUnits?.[0]; - if (!unit?.bids) throw new Error('expected the last Prebid request to contain bids'); - return unit as Record & { code?: string; bids: TestBid[] }; - } - - function refreshBidFromLastRequest(index = 0): TestBid & { params: Record } { - const bid = refreshAdUnitFromLastRequest().bids[index]; - if (!bid?.params) throw new Error(`expected refresh bid ${index} to contain params`); - return bid as TestBid & { params: Record }; - } - - function completePublisherAuction( - opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: unknown[]) => void }, - options: { auctionId?: string; applyTargeting?: boolean } = {} - ): void { - const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; - const bidResponses: Record> }> = {}; - - for (const unit of opts?.adUnits ?? []) { - if (!unit.code) continue; - const adId = `${auctionId}-${unit.code}`; - bidResponses[unit.code] = { - bids: [{ adId, adUnitCode: unit.code, auctionId }], - }; - if (options.applyTargeting !== false) { - const slot = installedGptSlots.find((candidate) => { - const getSlotElementId = candidate?.getSlotElementId; - const elementId = - typeof getSlotElementId === 'function' - ? (getSlotElementId as () => string).call(candidate) - : undefined; - return elementId === unit.code || elementId === `${unit.code}-container`; - }); - if (slot) deliveryAdIds.set(slot, adId); - } - } - - opts?.bidsBackHandler?.(bidResponses, false, auctionId); - } - - it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; - const runtimeInstance = 'example-runtime-instance'; - const code = `example-slot-${runtimeInstance}`; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const firstParams = { placement: 'first' }; - const effectiveParams = { placement: 'effective' }; - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, - bids: [ - { bidder: 'exampleServer', params: firstParams }, - { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, - { bidder: 'exampleServer', params: effectiveParams }, - { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, - ], - }, - ], - } as unknown as RequestBidsArg); - effectiveParams.placement = 'changed-after-auction'; - - pubads.refresh([slot]); - - expect(mockPbjs.adUnits).toEqual([]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(refreshAdUnitFromLastRequest()).toEqual({ - code, - mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, - bids: [ - { - bidder: 'trustedServer', - params: { - bidderParams: { exampleServer: { placement: 'effective' } }, - zone: 'example-zone', - }, - }, - { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, - { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, - ], - }); - }); - - it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; - const code = 'example-nested-params-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const serverParams = { - placement: { - rules: [{ label: 'original-rule' }], - sizes: [300, 250], - }, - }; - const browserParams = { - groups: [{ values: ['original-value'] }], - }; - - pbjs.requestBids({ - adUnits: [ - { - code, - bids: [ - { bidder: 'exampleServer', params: serverParams }, - { bidder: 'exampleBrowser', params: browserParams }, - ], - }, - ], - } as unknown as RequestBidsArg); - serverParams.placement.rules[0]!.label = 'changed-rule'; - serverParams.placement.sizes.push(999); - browserParams.groups[0]!.values[0] = 'changed-value'; - - pubads.refresh([slot]); - - const expectedBids = [ - { - bidder: 'trustedServer', - params: { - bidderParams: { - exampleServer: { - placement: { - rules: [{ label: 'original-rule' }], - sizes: [300, 250], - }, - }, - }, - }, - }, - { - bidder: 'exampleBrowser', - params: { groups: [{ values: ['original-value'] }] }, - }, - ]; - const firstRefreshBids = refreshAdUnitFromLastRequest().bids; - expect(firstRefreshBids).toEqual(expectedBids); - - const mutableServerParams = firstRefreshBids[0]!.params as { - bidderParams: { - exampleServer: { placement: { rules: Array<{ label: string }>; sizes: number[] } }; - }; - }; - const mutableBrowserParams = firstRefreshBids[1]!.params as { - groups: Array<{ values: string[] }>; - }; - mutableServerParams.bidderParams.exampleServer.placement.rules[0]!.label = - 'changed-refresh-rule'; - mutableServerParams.bidderParams.exampleServer.placement.sizes.push(777); - mutableBrowserParams.groups[0]!.values[0] = 'changed-refresh-value'; - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); - }); - - it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { - const code = 'example-dynamic-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, - bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], - }, - ], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - expect(refreshBidFromLastRequest().params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pubads.refresh([slot]); - expect(refreshBidFromLastRequest().params).toEqual({ - bidderParams: { exampleServer: { placement: 'one' } }, - zone: 'example-zone-one', - }); - - pbjs.requestBids({ - adUnits: [ - { - code, - mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, - bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], - }, - ], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(refreshBidFromLastRequest().params).toEqual({ - bidderParams: { exampleServer: { placement: 'two' } }, - zone: 'example-zone-two', - }); - }); - - it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { - const slotOne = { - getSlotElementId: () => 'example-code-one', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const slotTwo = { - getSlotElementId: () => 'example-code-two', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const globalSlot = { - getSlotElementId: () => 'example-global-code', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code: 'example-code-one', - bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], - }, - { - code: 'example-code-two', - bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], - }, - ], - } as unknown as RequestBidsArg); - mockPbjs.adUnits = [ - { - code: 'example-global-code', - bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], - }, - ]; - - pubads.refresh([slotOne]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: 'one' }, - }); - pubads.refresh([slotTwo]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: 'two' }, - }); - pubads.refresh([globalSlot]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleFallback: { placement: 'global' }, - }); - }); - - it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; - const code = 'example-live-rich-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const liveUnit = { - code, - bids: [ - { bidder: 'exampleServer', params: { placement: 'live-server' } }, - { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, - ], - }; - mockPbjs.adUnits = [liveUnit]; - const pbjs = installPrebidNpm(); - - pbjs.requestBids(); - pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { - bidder: 'trustedServer', - params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, - }, - { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, - ]); - }); - - it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { - const code = 'example-live-empty-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], - } as unknown as RequestBidsArg); - mockPbjs.adUnits = [{ code, bids: [] }]; - pubads.refresh([slot]); - - expect(refreshAdUnitFromLastRequest().bids).toEqual([ - { bidder: 'trustedServer', params: { bidderParams: {} } }, - ]); - }); - - it('evicts snapshots with the matching removeAdUnit lifecycle', () => { - const codes = ['example-remove-one', 'example-remove-two', 'example-remove-all']; - const slots = codes.map((code) => ({ - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - })); - const { pubads } = installGpt(slots); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: codes.map((code) => ({ - code, - bids: [{ bidder: 'exampleServer', params: { placement: code } }], - })), - } as unknown as RequestBidsArg); - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - codes[0]! - ); - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit([ - codes[1]!, - ]); - - pubads.refresh([slots[0]!]); - expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[1]!]); - expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - pubads.refresh([slots[2]!]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: codes[2]! }, - }); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit(); - pubads.refresh([slots[2]!]); - expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - }); - - it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { - const capacity = 256; - const oldestCode = 'example-lru-0'; - const activeCode = `example-lru-${capacity - 1}`; - const oldestSlot = { - getSlotElementId: () => oldestCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const activeSlot = { - getSlotElementId: () => activeCode, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([oldestSlot, activeSlot]); - const pbjs = installPrebidNpm(); - - for (let index = 0; index < capacity; index += 1) { - pbjs.requestBids({ - adUnits: [ - { - code: `example-lru-${index}`, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - }, - ], - } as unknown as RequestBidsArg); - } - - pubads.refresh([activeSlot]); - pbjs.requestBids({ - adUnits: [ - { - code: `example-lru-${capacity}`, - bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], - }, - ], - } as unknown as RequestBidsArg); - - pubads.refresh([oldestSlot]); - expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); - pubads.refresh([activeSlot]); - expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ - exampleServer: { placement: capacity - 1 }, - }); - }); - - it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { - const slotOne = { - getSlotElementId: () => 'example-covered-one', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const slotTwo = { - getSlotElementId: () => 'example-covered-two-container', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - testWindow.tsjs = { - adSlots: [ - { - id: 'example-covered-two', - div_id: 'example-covered-two', - gam_unit_path: '/example/covered-two', - formats: [[300, 250]], - targeting: {}, - }, - ], - }; - const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, - { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pubads.refresh([slotOne]); - pubads.refresh([slotTwo]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slotOne.clearTargeting).not.toHaveBeenCalled(); - expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); - }); - - it('registers delivery state for a publisher auction without a bidsBackHandler', () => { - const code = 'example-handlerless-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('preserves one mixed refresh request and its original options', () => { - const deliverySlot = { - getSlotElementId: () => 'example-sra-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const independentSlot = { - getSlotElementId: () => 'example-sra-independent', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshOptions = { changeCorrelator: true }; - const { originalRefresh, pubads } = installGpt([deliverySlot, independentSlot]); - let syntheticBidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - if (mockRequestBids.mock.calls.length === 1) { - completePublisherAuction(opts); - } else { - syntheticBidsBackHandler = opts.bidsBackHandler; - } - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-sra-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([deliverySlot, independentSlot], refreshOptions), - } as unknown as RequestBidsArg); - - expect(originalRefresh).not.toHaveBeenCalled(); - expect(independentSlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - - syntheticBidsBackHandler?.(); - - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([deliverySlot, independentSlot], refreshOptions); - }); - - it('partitions a bare delivery refresh from an unmatched GPT slot', () => { - const coveredSlot = { - getSlotElementId: () => 'example-covered', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh(), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); - }); - - it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { - const coveredSlot = { - getSlotElementId: () => 'example-covered', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const unrelatedSlot = { - getSlotElementId: () => 'example-unrelated', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - pubads.refresh([unrelatedSlot]); - pubads.refresh([coveredSlot, unrelatedSlot]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect( - mockRequestBids.mock.calls[1]![0].adUnits.map((unit: { code?: string }) => unit.code) - ).toEqual(['example-unrelated']); - expect( - mockRequestBids.mock.calls[2]![0].adUnits.map((unit: { code?: string }) => unit.code) - ).toEqual(['example-unrelated']); - expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); - expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); - }); - - it('partitions four delivered slots from an unmatched explicit slot', () => { - const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ - getSlotElementId: () => `example-covered-${index}`, - getTargeting: () => [], - clearTargeting: vi.fn(), - })); - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshSlots = [...coveredSlots, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: coveredSlots.map((_, index) => ({ - code: `example-covered-${index}`, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - })), - bidsBackHandler: () => pubads.refresh(refreshSlots), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - }); - - it('expires an unconsumed publisher delivery before a later refresh', () => { - vi.useFakeTimers(); - try { - const code = 'example-expired-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - vi.advanceTimersByTime(5001); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('expires an unconsumed targeted delivery before a later refresh', () => { - vi.useFakeTimers(); - try { - const code = 'example-expired-targeted-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - vi.advanceTimersByTime(5001); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('correlates a targeted delivery refresh after more than one second without a timer race', () => { - vi.useFakeTimers(); - try { - const code = 'example-delayed-delivery'; - const auctionId = 'example-delayed-auction'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - setTimeout(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - pubads.refresh([slot]); - }, 1500); - }, - } as unknown as RequestBidsArg); - - vi.advanceTimersByTime(1500); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('correlates null and no-argument targeting with a custom GPT slot match', () => { - const code = 'example-custom-matched-code'; - const slot = { - getSlotElementId: () => 'example-different-gpt-slot', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - let auctionId = 'example-null-auction'; - const setTargetingForGPTAsync = vi.fn(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - }); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - ( - pbjs as unknown as { - setTargetingForGPTAsync: ( - codes?: string[] | null, - customSlotMatching?: () => (slot: unknown) => boolean - ) => void; - } - ).setTargetingForGPTAsync(null, () => () => true); - pubads.refresh([slot]); - }, - } as unknown as RequestBidsArg); - - auctionId = 'example-no-argument-auction'; - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } - ).setTargetingForGPTAsync(); - pubads.refresh([slot]); - }, - } as unknown as RequestBidsArg); - - expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); - expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - mockPbjs.setTargetingForGPTAsync = undefined; - }); - - it('correlates requested no-bid slots without manufacturing unrelated bid state', () => { - const slot = { - getSlotElementId: () => 'example-no-bid-delivery', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation( - (opts?: { bidsBackHandler?: (...args: unknown[]) => void }) => { - opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); - } - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('bounds code-only delivery correlation to one suppressed independent refresh', () => { - const code = 'example-code-only-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - // Model an initial impression rendered with display() after an auction - // that did not apply hb_adid targeting. Its code-only state is unconsumed. - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(2); - }); - - it('does not use code fallback when a slot has an unmatched hb_adid', () => { - const code = 'example-stale-targeting'; - const slot = { - getSlotElementId: () => code, - getTargeting: (key: string) => (key === 'hb_adid' ? ['example-stale-ad-id'] : []), - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('uses an independent auction when a pending hb_adid exceeds the capacity bound', () => { - const capacity = 2048; - const code = 'example-capacity-delivery'; - const oldestAdId = 'example-capacity-ad-0'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => { - if (mockRequestBids.mock.calls.length === 1) { - opts.bidsBackHandler?.({ - [code]: { - bids: Array.from({ length: capacity + 1 }, (_, index) => ({ - adId: `example-capacity-ad-${index}`, - adUnitCode: code, - })), - }, - }); - return; - } - completePublisherAuction(opts); - }); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - } as unknown as RequestBidsArg); - deliveryAdIds.set(slot, oldestAdId); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('bypasses a mixed explicit delivery list spanning nested contexts', () => { - const outerSlot = { - getSlotElementId: () => 'example-outer-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const innerSlot = { - getSlotElementId: () => 'example-inner-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const gamOnlySlot = { - getSlotElementId: () => 'example-gam-only-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pbjs.requestBids({ - adUnits: [ - { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh(refreshSlots), - } as unknown as RequestBidsArg); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - }); - - it('correlates a microtask refresh by its requested code without targeting', async () => { - const slot = { - getSlotElementId: () => 'example-deferred-refresh', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - let deferredRefresh: Promise | undefined; - - pbjs.requestBids({ - adUnits: [ - { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); - }, - } as unknown as RequestBidsArg); - await deferredRefresh; - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('correlates targeting and refresh deferred together to a microtask', async () => { - const code = 'example-targeted-microtask'; - const auctionId = 'example-targeted-microtask-auction'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { auctionId, applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - let deferredRefresh: Promise | undefined; - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => { - deferredRefresh = Promise.resolve().then(() => { - deliveryAdIds.set(slot, `${auctionId}-${code}`); - pubads.refresh([slot]); - }); - }, - } as unknown as RequestBidsArg); - await deferredRefresh; - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('consumes all overlapping pending bids for the same ad-unit code', () => { - const code = 'example-overlapping-code'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => {}, - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - - deliveryAdIds.set(slot, `example-auction-0-${code}`); - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(3); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); - }); - - it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { - const code = 'example-valid-delivery'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: () => - pubads.refresh([slot, undefined, null] as unknown as Array>), - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledWith([slot, undefined, null], undefined); - - pubads.refresh([slot]); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(slot.clearTargeting).not.toHaveBeenCalled(); - }); - - it('does not mutate reused publisher request options', () => { - const code = 'example-reused-request'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - const request = { - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - }; - - pbjs.requestBids(request as unknown as RequestBidsArg); - pbjs.requestBids(request as unknown as RequestBidsArg); - pubads.refresh([slot]); - - expect(request).not.toHaveProperty('bidsBackHandler'); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('falls back to one GPT refresh when a synthetic auction throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-refresh', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation(() => { - throw new Error('example synthetic failure'); - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); - expect(setTargetingForGPTAsync).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('applies targeting before falling back when a synthetic auction never calls back', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-missing-refresh-callback', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - mockRequestBids.mockImplementation(() => undefined); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(originalRefresh).not.toHaveBeenCalled(); - vi.advanceTimersByTime(640); - - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-missing-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]!).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0]! - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('applies fallback targeting once and ignores a late synthetic callback', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-late-refresh-callback', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - const setTargetingForGPTAsync = vi.fn(); - mockPbjs.setTargetingForGPTAsync = setTargetingForGPTAsync; - let syntheticBidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - syntheticBidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - vi.advanceTimersByTime(640); - syntheticBidsBackHandler?.(); - - expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-late-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]!).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0]! - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('completes a synthetic refresh when targeting throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-targeting', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockPbjs.setTargetingForGPTAsync = vi.fn(() => { - throw new Error('example targeting failure'); - }); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not stack the removeAdUnit lifecycle wrapper across installation', () => { - const pbjs = installPrebidNpm(); - installPrebidNpm(); - - (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - 'example-reinstalled-slot' - ); - - expect(mockRemoveAdUnit).toHaveBeenCalledTimes(1); - }); - - it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { - const outerSlot = { - getSlotElementId: () => 'example-outer-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const innerSlot = { - getSlotElementId: () => 'example-inner-delivery', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => { - pbjs.requestBids({ - adUnits: [ - { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([innerSlot]), - } as unknown as RequestBidsArg); - pubads.refresh([outerSlot]); - }, - } as unknown as RequestBidsArg); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); - }); - - it('cleans delivery context after a publisher callback throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-callback', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => - completePublisherAuction(opts, { applyTargeting: false }) - ); - const pbjs = installPrebidNpm(); - - expect(() => - pbjs.requestBids({ - adUnits: [ - { - code: 'example-throwing-callback', - bids: [{ bidder: 'exampleServer', params: {} }], - }, - ], - bidsBackHandler: () => { - throw new Error('example callback failure'); - }, - } as unknown as RequestBidsArg) - ).toThrow('example callback failure'); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - }); - - it('completes an internal synthetic refresh once without recursion', () => { - const slot = { - getSlotElementId: () => 'example-independent-refresh', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); -}); - -describe('prebid/client-side bidders', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPbjs.requestBids = mockRequestBids; - mockPbjs.adUnits = []; - mockGetUserIdsAsEids.mockReset(); - mockGetUserIdsAsEids.mockReturnValue([]); - // By default the manifest declares all adapters compiled in. - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - delete testWindow.__tsjs_prebid; - }); - - afterEach(() => { - delete testWindow.__tsjs_prebid; - }); - - it('excludes client-side bidders from trustedServer bidderParams', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'kargo', params: { placementId: 'k1' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid).toBeDefined(); - // rubicon should NOT be in bidderParams — it runs client-side - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - kargo: { placementId: 'k1' }, - }); - }); - - it('preserves client-side bidder bids as standalone entries', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // rubicon bid should remain untouched as a standalone entry - const rubiconBid = adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'rubicon') as TestBid; - expect(rubiconBid).toBeDefined(); - expect(rubiconBid.params).toEqual({ accountId: 'abc' }); - expect(adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); - }); - - it('handles multiple client-side bidders', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'openx', params: { unit: '456' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - // Only appnexus should be in bidderParams - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - }); - - // Both client-side bidders should remain - expect(adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'rubicon')).toBeDefined(); - expect(adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'openx')).toBeDefined(); - expect(adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); - }); - - it('behaves normally when no client-side bidders are configured', () => { - // No __tsjs_prebid at all — all bidders go server-side - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('behaves normally when client-side bidders list is empty', () => { - testWindow.__tsjs_prebid = { clientSideBidders: [] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'appnexus', params: { placementId: 123 } }, - { bidder: 'rubicon', params: { accountId: 'abc' } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid.params.bidderParams).toEqual({ - appnexus: { placementId: 123 }, - rubicon: { accountId: 'abc' }, - }); - }); - - it('still injects trustedServer when all bidders are client-side', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'appnexus'] }; - - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'rubicon', params: { accountId: 'abc' } }, - { bidder: 'appnexus', params: { placementId: 123 } }, - ], - }, - ]; - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - // trustedServer should still be present (even with empty bidderParams) - const tsBid = trustedServerBid(adUnits[0]!); - expect(tsBid).toBeDefined(); - expect(tsBid.params.bidderParams).toEqual({}); - }); - - it('logs error when a client-side bidder has no adapter in the external bundle', () => { - // rubicon is compiled into the external bundle, but openx is not - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['rubicon'], - bidderCodes: ['rubicon'], - }; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon', 'openx'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - // Should log an error for the missing adapter. - // log.error() uses styled console output: console.error('%c[tsjs]%c ...:', style, reset, ...args) - // so the actual message is the 4th argument. - const errorCalls = errorSpy.mock.calls; - const hasOpenxError = errorCalls.some((args) => - args.some( - (a) => - typeof a === 'string' && - a.includes('client-side bidder "openx" has no adapter in the external Prebid bundle') - ) - ); - expect(hasOpenxError).toBe(true); - - // The error should point at the operator surface: the CLI config key, - // not the internal build script. - const pointsAtBundleConfig = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('[integrations.prebid.bundle].adapters')) - ); - expect(pointsAtBundleConfig).toBe(true); - - // Should NOT log an error for the compiled-in adapter - const hasRubiconError = errorCalls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('client-side bidder "rubicon"')) - ); - expect(hasRubiconError).toBe(false); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('accepts alias bidder codes stamped in bidderCodes', () => { - // The adf module registers adf plus the adform/adformOpenRTB aliases; - // the module-name list alone would flag them as missing. - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['adf'], - bidderCodes: ['adf', 'adform', 'adformOpenRTB'], - }; - testWindow.__tsjs_prebid = { clientSideBidders: ['adform'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(false); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('rejects a module file stem that is not a registered bidder code', () => { - // a1MediaBidAdapter.js registers a1media — configuring the file stem - // must be flagged even though the module itself is compiled in. - testWindow.__tsjs_prebid_bundle = { - ...DEFAULT_BUNDLE_MANIFEST, - adapters: ['a1Media'], - bidderCodes: ['a1media'], - }; - testWindow.__tsjs_prebid = { clientSideBidders: ['a1Media'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => - typeof a === 'string' && - a.includes('client-side bidder "a1Media" has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(true); - - errorSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('treats a malformed manifest as unstamped instead of throwing', () => { - // The manifest is a plain window global any page script can overwrite. - testWindow.__tsjs_prebid_bundle = { adapters: 'rubicon', userIdModules: 42 }; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - expect(() => installPrebidNpm()).not.toThrow(); - - const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) - ); - expect(hasManifestWarn).toBe(true); - - warnSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('warns when the external bundle stamped no adapter manifest', () => { - delete testWindow.__tsjs_prebid_bundle; - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasManifestWarn = warnSpy.mock.calls.some((args) => - args.some((a) => typeof a === 'string' && a.includes('did not stamp an adapter manifest')) - ); - expect(hasManifestWarn).toBe(true); - - warnSpy.mockRestore(); - testWindow.__tsjs_prebid_bundle = DEFAULT_BUNDLE_MANIFEST; - }); - - it('does not log errors when all client-side bidders have adapters', () => { - testWindow.__tsjs_prebid = { clientSideBidders: ['rubicon'] }; - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - installPrebidNpm(); - - const hasAdapterError = errorSpy.mock.calls.some((args) => - args.some( - (a) => typeof a === 'string' && a.includes('has no adapter in the external Prebid bundle') - ) - ); - expect(hasAdapterError).toBe(false); - - errorSpy.mockRestore(); - }); -}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts index f23e86c25..e640024d5 100644 --- a/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts @@ -4,7 +4,7 @@ import { disposeSourcepointConsentMirror, initializeSourcepointConsentMirror, mirrorSourcepointConsent, -} from '../../../src/integrations/sourcepoint'; +} from '../../../src/integrations/sourcepoint/consent_mirror'; import { createSourcepointRuntime } from '../../../src/integrations/sourcepoint/module'; describe('Sourcepoint integration initialization', () => { diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 850c68332..e5e883b9b 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -156,7 +156,7 @@ sequenceDiagram %% === Creative Rendering === rect rgb(239,246,255) Note over Client,Mock: Creative Rendering - Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer + Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer/v1 Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame deactivate Client end @@ -710,16 +710,16 @@ environment overrides to apply; see #### `[integrations.aps]` -| Field | Type | Default | Description | -| ------------------------ | ------ | ----------------------------- | ----------------------------------------------------------------- | -| `enabled` | bool | `false` | Enable APS provider | -| `account_id` | string | — | APS account ID (required; `pub_id` is an alias) | -| `endpoint` | string | Built-in APS OpenRTB endpoint | Optional APS OpenRTB endpoint override | -| `timeout_ms` | u32 | `800` | Request timeout | -| `debug` | bool | `false` | Include the raw APS HTTP exchange in `/auction` provider metadata | -| `inventory_domain` | string | — | Override `site.domain` for APS-authorized inventory | -| `inventory_page_origin` | string | — | HTTPS origin paired with `inventory_domain` for `site.page` | -| `allow_script_creatives` | bool | `false` | Admit script bids before APS candidate reduction | +| Field | Type | Default | Description | +| ------------------------ | ----------------- | ----------------------------- | ----------------------------------------------------------------- | +| `enabled` | bool | `false` | Enable APS provider | +| `account_id` | string or integer | — | APS account ID (required) | +| `endpoint` | string | Built-in APS OpenRTB endpoint | Optional APS OpenRTB endpoint override | +| `timeout_ms` | u32 | `800` | Request timeout | +| `debug` | bool | `false` | Include the raw APS HTTP exchange in `/auction` provider metadata | +| `inventory_domain` | string | — | Override `site.domain` for APS-authorized inventory | +| `inventory_page_origin` | string | — | HTTPS origin paired with `inventory_domain` for `site.page` | +| `allow_script_creatives` | bool | `false` | Admit script bids before APS candidate reduction | #### `[integrations.adserver_mock]` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ec969308c..bb8c93ede 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -662,11 +662,9 @@ fetches never carry Basic credentials, so every visitor gets `401` — on `/_ts/page-bids` that means no ads after any client-side navigation. Match the admin routes specifically (`^/_ts/admin`) instead. -Upgrading from a release before `/_ts/page-bids` existed: if any handler -pattern covers it, narrow the pattern. The Trusted Server JS bundle falls back -to the deprecated `/__ts/page-bids` alias in the meantime, but that alias is -scheduled for removal -([#970](https://github.com/IABTechLab/trusted-server/issues/970)). +If an older deployment used a different SPA auction path, update its handler +rules at the same time as the TSJS cutover. `/_ts/page-bids` is the only SPA +auction endpoint; older path spellings are unknown routes. ::: diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index abaf617c9..9e56ead6c 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -96,8 +96,9 @@ runtime's click guard recovers mutated clicks there via a GET One capability is unavailable in that context: **dynamic** resource signing, which rewrites URLs on elements a creative inserts at runtime. It is installed -only when `renderGuard` is enabled in `tsCreativeConfig`, and that is `false` -by default — deployments using the default configuration are unaffected. Where +only when `renderGuard` is enabled in the immutable +`window.tsjs.boot.creative` configuration, and that is `false` by default — +deployments using the default configuration are unaffected. Where it is enabled, runtime-inserted ``/`